⛅ SWCloud Reference

A SketchWave composite class that builds a cumulus cloud from N overlapping SWEllipse puffs

Back to SWCloud Demo Making SWCloud Tutorial

Quick Reference

SWCloud is a SketchWave composite class that builds a cumulus-style cloud shape from N overlapping SWEllipse puffs. Each puff is a horizontally-elongated ellipse placed at a random offset around a shared center. The offsets (dx, dy, size) are stored independently so spread sliders rescale the cloud without losing its random shape. SWCloud owns no visible anchor point of its own — all drawing is delegated to its internal _puffs[] array.

  • Design Pattern: Composite — owns an array of SWEllipse puffs
  • Internal Structure: _offsets[] (random dx/dy/size per puff) + _puffs[] (SWEllipse array)
  • Dependencies: SWPoint, SWColor, SWSinusoid, SWEllipse, SWGrid, p5.js
  • Key Features: Random puff layout, spread/spreadV scatter sliders, breathe animation with stagger, horizontal drift + canvas wrap, Perlin-noise morph per puff, showCenters toggle, wrapGap off-canvas overshoot, full reset
  • Getters: width, height — approximate bounding box in user units
  • Common Uses: Sky scenes, weather graphics, cumulus cloud shapes, organic animated backgrounds, character art

Overview

The SWCloud class represents a cumulus cloud shape. The cloud is defined by a center SWPoint and a set of options that control puff shape, scatter, and animation. Each puff is an SWEllipse placed at a random normalized offset (dx, dy) around the center, scaled by spread × puffWidth horizontally and spreadV × puffHeight vertically. Each puff also gets a random size scale (0.70–1.00 of the base puff dimensions).

Offsets are stored, not recalculated:
The random dx, dy, and size for each puff are computed once and stored in _offsets[]. Sliders like Spread H and Puff Width rescale the existing offsets, preserving the cloud's random character. Call randomize() (or press z) to generate a completely new layout.
Rebuild vs. Update:
Most setters call _buildPuffs(), which creates a new SWEllipse array from the current offsets. Color and stroke setters iterate the existing _puffs[] and update properties in-place (no rebuild). Drift and noise morph update puff positions every frame in-place via update(), avoiding 30 fps object creation.

Puff Layout

In user coordinates, puff i is placed at:

px = center.x + offsets[i].dx * spread  * puffWidth
py = center.y + offsets[i].dy * spreadV * puffHeight
rX = puffWidth  * offsets[i].size
rY = puffHeight * offsets[i].size

Setting spread = 0 stacks all puffs at the center. Increasing it spreads them outward without changing their relative arrangement.

Breathe Animation

Calling breathe(sinX, sinY, t) delegates to each puff with a phase offset of i × breatheStagger seconds. At breatheStagger = 0 all puffs pulse together; with stagger > 0 adjacent puffs breathe slightly out of phase, producing a rolling, organic billowing motion.

Drift & Noise Morph

Call update(t, deltaT, …) once per frame when drift or noise morph is active. Drift advances center.x each frame and wraps the cloud when it travels past the canvas edge plus wrapGap user units. On each wrap, a small random vertical nudge shifts center.y so each pass crosses the sky at a slightly different altitude. Noise Morph adds an independent Perlin-noise displacement to each puff's position every frame, making the cloud shape shift slowly and organically.

Key Capabilities

  • N-puff Composite: 1–9 SWEllipse puffs drawn as a single cloud shape
  • Random Layout: Every cloud looks different; randomize() generates a fresh arrangement without changing any parameters
  • Spread Control: Independent horizontal (spread) and vertical (spreadV) scatter magnitudes
  • Breathe: Staggered sinusoidal puff-size animation; configure period, min/max scale, and stagger offset
  • Drift: Horizontal motion with canvas wrap and per-pass vertical nudge
  • Noise Morph: Per-puff Perlin-noise displacement — shape shifts continuously
  • Show Centers: setShowCenters(true) reveals each puff's SWPoint center dot for layout inspection
  • Drag: Move the cloud interactively by dragging the center crosshair in the demo
  • Full Reset: reset() restores the exact original cloud — same random offsets, same center, same parameters

Typical Workflow

  1. Create fill and stroke SWColor instances
  2. Construct an SWCloud with a center SWPoint and an options object
  3. Draw each frame using cloud.drawOnGrid(grid)
  4. If breathe is active, call cloud.breathe(sinX, sinY, t) after drawing
  5. If drift or noise morph is active, call cloud.update(t, deltaT, …) before drawing
  6. Call cloud.reset() to restore all original values and the original random shape

Constructor

new SWCloud(center, options)

Creates a new SWCloud instance. Random puff offsets are generated at construction and deep-copied into originalOffsets[] so reset() can restore the exact original shape. All numeric option values are also preserved as originalXxx properties for reset.

Parameters
Parameter / Option Type Default Description
center SWPoint required Anchor of the cloud in user (grid) coordinates. All puffs are placed relative to this point.
options.puffCount number 5 Number of SWEllipse puffs. Clamped to ≥ 1. Changing the count generates new random offsets for all puffs.
options.puffWidth number 1.8 Base horizontal radius (radiusX) of each puff in user units. puffWidth > puffHeight produces the typical horizontally-elongated cloud shape.
options.puffHeight number 1.0 Base vertical radius (radiusY) of each puff in user units.
options.spread number 1.0 Horizontal scatter scale. 0 = all puffs stacked at center; higher values stretch the cloud wider.
options.spreadV number 0.6 Vertical scatter scale. Increase for a taller cloud; decrease for a flat stratus band.
options.fillColor SWColor | undefined undefined Fill color applied to all puffs. undefined = no fill.
options.strokeColor SWColor | undefined undefined Border color applied to all puffs. undefined = no border.
options.strokeWeight number 1 Border thickness in pixels applied to all puffs.
options.showCenters boolean false Whether to display a SWPoint dot at each puff's center. Useful for layout inspection.
options.breatheStagger number 0.3 Phase offset in seconds between adjacent puff breaths. 0 = all puffs pulse simultaneously.
options.driftSpeed number 0 Horizontal drift speed in user units/second. Positive = rightward; negative = leftward. 0 = no drift.
options.noiseAmp number 0.3 Perlin-noise morph amplitude in user units. 0 = no displacement; larger values make puffs wander more.
options.noiseSpeed number 0.3 How fast the Perlin-noise pattern evolves. Low = slow organic shift; high = rapid turbulent morphing.
options.wrapGap number 2.0 How far (user units) past the canvas edge the cloud travels before reappearing on the opposite side. 0 = instant re-entry.
Constructor Examples
// Default sky-blue cloud centered at origin
const center = new SWPoint(0, 0);
const fill   = new SWColor(200, 12, 98, 90, "cloudFill");
const stroke = new SWColor(230, 30, 60, 100, "cloudStroke");
let cloud = new SWCloud(center, {
    puffCount: 5,
    puffWidth: 1.8,
    puffHeight: 1.0,
    fillColor: fill,
    strokeColor: stroke,
});

// White storm cloud — more puffs, taller, more spread
let stormCloud = new SWCloud(new SWPoint(-3, 2), {
    puffCount:  8,
    puffWidth:  2.0,
    puffHeight: 1.5,
    spread:     1.4,
    spreadV:    0.9,
    fillColor:  new SWColor(0, 0, 85, 95, "stormFill"),
    strokeColor: new SWColor(0, 0, 40, 100, "darkBorder"),
    strokeWeight: 2,
});

// Drifting cloud — moves right, Perlin noise morph active
let driftCloud = new SWCloud(new SWPoint(-10, 1), {
    puffCount:   5,
    puffWidth:   2.0,
    puffHeight:  1.0,
    fillColor:   fill,
    driftSpeed:  2.0,    // 2 user units/second rightward
    noiseAmp:    0.3,
    noiseSpeed:  0.25,
    wrapGap:     2.0,
});

Properties

center SWPoint

The cloud's anchor point in user (grid) coordinates. All puffs are positioned relative to this. Moving center.x / center.y directly does not rebuild puffs — use setCenter(x, y) to reposition and rebuild, or the demo's drag interaction to move the cloud interactively.

cloud.setCenter(2, 1); // move anchor and rebuild puffs
puffCount number

Number of SWEllipse puffs. Clamped to ≥ 1 by setPuffCount(). Changing the count calls _generateOffsets() to create a fresh set of random offsets for the new count, then rebuilds all puffs.

cloud.setPuffCount(7); // new random offsets + rebuild
puffWidth number  /  puffHeight number

puffWidth is the base horizontal radius (radiusX) of each puff; puffHeight is the base vertical radius (radiusY). Both are in user units. Each puff's actual radii are puffWidth × offset.size and puffHeight × offset.size, where offset.size is in [0.70, 1.00]. Setting either calls _buildPuffs().

cloud.setPuffWidth(2.5); // wider puffs
cloud.setPuffHeight(1.4); // taller puffs
spread number  /  spreadV number

spread scales how far puffs scatter horizontally; spreadV scales vertical scatter. Both multiply the normalized offset values stored in _offsets[]. Setting 0 collapses all puffs to the center; higher values open the cloud up without changing the random arrangement.

cloud.setSpread(1.5); // wider scatter
cloud.setSpreadV(0.3); // flatter, more stratus-like
fillColor SWColor | undefined  /  strokeColor SWColor | undefined

Fill and border color applied to all puffs. undefined = no fill or no border. Setting either iterates _puffs[] and updates each puff's color in-place (no rebuild). Deep-copied at construction for reset().

cloud.setFillColor(new SWColor(200, 15, 98, 80, "skyFill"));
cloud.setStrokeColor(undefined); // no border
strokeWeight number

Border thickness in pixels applied to all puffs. Use setStrokeWeight() to change; iterates _puffs[] in-place.

cloud.setStrokeWeight(2); // thicker border
showCenters boolean

Whether each puff's SWPoint center dot is visible. Default false. Use setShowCenters() to toggle. Useful for understanding puff placement during development.

cloud.setShowCenters(true); // reveal puff dots
cloud.setShowCenters(false); // hide them again
breatheStagger number

Phase offset in seconds between adjacent puff breaths. At 0, all puffs pulse simultaneously (uniform inflation). Increasing stagger creates a rolling, wave-like billowing effect across the cloud.

cloud.setBreatheStagger(0.5); // more dramatic wave effect
driftSpeed number

Horizontal drift speed in user units/second. Positive = rightward; negative = leftward; 0 = stationary. Applied each frame by update() when drift is active. Use setDriftSpeed() to change.

cloud.setDriftSpeed(1.5); // moderate rightward drift
cloud.setDriftSpeed(-2.0); // leftward drift
noiseAmp number  /  noiseSpeed number

noiseAmp controls the maximum Perlin-noise displacement of each puff from its base position (in user units). noiseSpeed controls how fast the noise pattern evolves. Small amp + slow speed = subtle shimmer; large amp + fast speed = turbulent morphing.

cloud.setNoiseAmp(0.6); // larger puff wander
cloud.setNoiseSpeed(0.5); // faster morphing
wrapGap number

How far (user units) past the canvas edge the cloud travels before reappearing on the opposite side. 0 = the cloud re-enters as soon as its leading edge clears the boundary; 2.0 = the cloud travels 2 more user units off-canvas before wrapping. Allows the full cloud to visually exit before returning.

cloud.setWrapGap(3.0); // longer off-canvas pause before wrap
width getter — number  /  height getter — number

Read-only approximate bounding-box dimensions in user units. width is computed from the leftmost and rightmost puff edges; height from the bottom and top puff edges. Useful for positioning logic, snap calculations, and HUD readouts.

console.log(`${cloud.width.toFixed(1)} × ${cloud.height.toFixed(1)} user units`);
originalCenter / originalPuffCount / originalPuffWidth / … / originalOffsets[] various restore targets

Snapshots captured at construction. reset() uses all of these to restore the cloud to its exact initial state — same dimensions, same colors, same random offset arrangement. originalOffsets[] is a deep copy of the random offsets generated at construction. originalFillColor and originalStrokeColor are deep-copied SWColor instances. Read-only; used internally by reset().

// Read-only; used internally by reset()
_puffs[] SWEllipse[] internal

The internal array of SWEllipse puffs. Rebuilt by _buildPuffs() whenever shape or layout properties change. Avoid direct access; use the public setter methods instead.

_offsets[] {dx, dy, size}[] internal

Normalized random offsets for each puff: dx and dy each in [−1, +1]; size in [0.70, 1.00]. Regenerated by setPuffCount() and randomize(); restored from originalOffsets[] by reset(). Avoid direct access.

Methods

Core Drawing Methods

drawOnGrid(grid)

Draws all puffs through the given SWGrid's coordinate system. Converts puff centers and radii from user units to screen pixels via grid.userToScreen(). This is the standard method to call in a p5.js draw() loop.

Parameters
  • grid (SWGrid) — the coordinate grid
Returns

void

Example
function draw() {
    background(bgColor);
    grid.draw();
    cloud.drawOnGrid(grid);
}
draw()

Draws all puffs in raw screen (pixel) coordinates. center.x / center.y and radii are treated as pixel values. Rarely used directly — prefer drawOnGrid() for standard canvas rendering.

Returns

void

Breathe Animation

breathe(sinusoidX, sinusoidY, t)

Delegates to each puff's breathe() with a phase offset of i × breatheStagger seconds. The sinusoid value at time t + i × stagger drives the scale of puff i's radiusX and/or radiusY. Call after drawOnGrid().

Parameters
  • sinusoidX (SWSinusoid | null) — controls radiusX oscillation; configure with adjustWaveUsingExtrema(min, max)
  • sinusoidY (SWSinusoid | null) — controls radiusY oscillation; can be the same sinusoid as X for uniform breathing
  • t (number) — elapsed time in seconds
Example
// Set up sinusoid to scale between 0.70× and 1.30× original size
const breatheSin = SWSinusoid.copy(UNIT_SINUSOID);
breatheSin.setPeriod(3.0);
breatheSin.adjustWaveUsingExtrema(0.70, 1.30);

// In draw():
cloud.drawOnGrid(grid);
cloud.breathe(breatheSin, breatheSin, elapsedSeconds); // call AFTER draw

Drift & Noise Morph Animation

update(t, deltaT, wrapLeft, wrapRight, doDrift, doNoise, wrapTop, wrapBottom)

Advances drift and/or Perlin-noise morphing for one frame. Call once per frame from draw() when either animation is active. Updates puff center positions in-place — does not rebuild SWEllipse objects.

Drift: advances center.x by driftSpeed × deltaT. When the cloud travels more than wrapGap user units past a wrap boundary, it is repositioned on the opposite side and its y-position is nudged by a random offset (±1.5 × puffHeight), clamped to keep the cloud fully on-canvas.

Noise Morph: displaces each puff's center from its base position using independent Perlin-noise channels (noise seed based on puff index and time). Displacement is scaled by noiseAmp.

Parameters
ParameterTypeDefaultDescription
tnumberrequiredElapsed time in seconds (used for Perlin noise time axis)
deltaTnumberrequiredSeconds since last frame (used for drift step = driftSpeed × deltaT)
wrapLeftnumber−15Left wrap boundary in user units (typically grid.UL.x)
wrapRightnumber15Right wrap boundary in user units (typically grid.LR.x)
doDriftbooleantrueWhether to advance horizontal drift this frame
doNoisebooleantrueWhether to apply Perlin-noise morph this frame
wrapTopnumber10Top canvas bound in user units (typically grid.UL.y) — used to clamp the vertical nudge on wrap
wrapBottomnumber−10Bottom canvas bound in user units (typically grid.LR.y) — used to clamp the vertical nudge on wrap
Returns

void

Example
// In draw() — call before drawOnGrid when either animation is active
if (shouldDrift || shouldNoiseMorph) {
    cloud.update(t, deltaT,
                 grid.UL.x, grid.LR.x,   // wrap boundaries
                 shouldDrift, shouldNoiseMorph,
                 grid.UL.y, grid.LR.y);   // top/bottom for nudge clamp
}
cloud.drawOnGrid(grid);

Layout Methods

randomize()

Generates a completely new set of random puff offsets and rebuilds all puffs. All other parameters (puffCount, puffWidth, spread, colors, etc.) remain unchanged. The new offsets are not saved as originals — calling reset() after randomize() will still restore the original construction-time offsets.

Returns

void

Example
cloud.randomize(); // new cloud layout, same parameters
reset()

Restores all original construction values — center position, puffCount, puffWidth, puffHeight, spread, spreadV, driftSpeed, noiseAmp, noiseSpeed, wrapGap, fillColor, strokeColor, strokeWeight — and rebuilds puffs using the original random offsets. The cloud returns to its exact initial appearance, including the same random arrangement.

Returns

void

Example
cloud.reset(); // full restore — same shape, same center, same parameters

Setter Methods

setCenter(x, y)

Repositions the cloud anchor and rebuilds all puffs around the new center.

Parameters
  • x, y (number) — new center position in user coordinates
cloud.setCenter(3, -1); // move cloud and rebuild puffs
setPuffCount(n)

Changes the number of puffs. Clamps to ≥ 1. Generates new random offsets for the new count and rebuilds. Useful when exploring how cloud density affects the shape.

Parameters
  • n (number) — new puff count; integer; clamped to ≥ 1
cloud.setPuffCount(8); // expand to 8 puffs with new layout
setPuffWidth(w)  /  setPuffHeight(h)

Change the base puff radiusX or radiusY and rebuild. The existing random offsets are preserved — only the scale changes.

cloud.setPuffWidth(2.2); // wider puffs
cloud.setPuffHeight(0.8); // flatter puffs
setSpread(s)  /  setSpreadV(v)

Change horizontal or vertical scatter magnitude and rebuild. 0 collapses all puffs to the center; 1 is the default; higher values spread the cloud out more.

cloud.setSpread(0); // all puffs stacked at center
cloud.setSpreadV(0.8); // taller arrangement
setFillColor(col)  /  setStrokeColor(col)

Update fill or border color on all puffs without rebuilding. Pass an SWColor to set a new color; pass undefined to remove fill or stroke.

cloud.setFillColor(new SWColor(0, 0, 95, 100, "white"));
cloud.setStrokeColor(undefined); // remove border
setStrokeWeight(w)

Update border thickness in pixels on all puffs without rebuilding.

cloud.setStrokeWeight(3);
setShowCenters(show)

Toggle the SWPoint center dot at each puff's position. Useful for inspecting the random layout during development or teaching.

Parameters
  • show (boolean) — true to reveal dots; false to hide
cloud.setShowCenters(true); // show puff center dots
cloud.setShowCenters(false); // hide them
setBreatheStagger(s)

Update the phase offset (seconds) between adjacent puff breaths. Higher values produce more dramatic wave-like motion across the cloud.

cloud.setBreatheStagger(0.6); // wider phase spread
setDriftSpeed(v)  /  setNoiseAmp(v)  /  setNoiseSpeed(v)  /  setWrapGap(v)

Update the drift and noise morph parameters without rebuilding puffs. Changes take effect on the next update() call.

cloud.setDriftSpeed(3.0); // faster drift
cloud.setNoiseAmp(0.5); // larger puff wander
cloud.setNoiseSpeed(0.4); // faster noise evolution
cloud.setWrapGap(4.0); // cloud exits fully before re-entering

Complete Usage Example

Load the dependencies in the correct order:

<script src="shapeClasses/swSinusoid.js"></script>
<script src="shapeClasses/swColor.js"></script>
<script src="shapeClasses/swPoint.js"></script>
<script src="shapeClasses/swGrid.js"></script>
<script src="shapeClasses/swEllipse.js"></script>   <!-- SWCloud depends on SWEllipse -->
<script src="shapeClasses/swCloud.js"></script>

Minimal sketch — a stationary white cloud on a sky-blue background:

let grid, cloud;

function setup() {
    createCanvas(500, 400);
    colorMode(HSB, 360, 100, 100, 100);
    initializeSWColors();

    grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });

    const fill   = new SWColor(200, 12, 98, 90, "cloudFill");
    const stroke = new SWColor(230, 30, 60, 100, "cloudStroke");

    cloud = new SWCloud(new SWPoint(0, 0), {
        puffCount:   5,
        puffWidth:   1.8,
        puffHeight:  1.0,
        fillColor:   fill,
        strokeColor: stroke,
    });

    frameRate(30);
}

function draw() {
    background(200, 55, 88); // sky blue
    grid.draw();
    cloud.drawOnGrid(grid);
}

Animated cloud — breathe + drift + noise morph:

let grid, cloud, breatheSin;
let prevT = 0;

function setup() {
    createCanvas(600, 400);
    colorMode(HSB, 360, 100, 100, 100);
    initializeSWColors();

    grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });

    const fill = new SWColor(200, 12, 98, 90, "cloudFill");
    cloud = new SWCloud(new SWPoint(-5, 1), {
        puffCount:   6,
        puffWidth:   2.0,
        puffHeight:  1.0,
        spread:      1.0,
        spreadV:     0.6,
        fillColor:   fill,
        driftSpeed:  1.5,
        noiseAmp:    0.3,
        noiseSpeed:  0.25,
        wrapGap:     2.0,
        breatheStagger: 0.3,
    });

    breatheSin = SWSinusoid.copy(UNIT_SINUSOID);
    breatheSin.setPeriod(3.0);
    breatheSin.adjustWaveUsingExtrema(0.70, 1.30);

    frameRate(30);
}

function draw() {
    const t = millis() / 1000;
    const deltaT = prevT > 0 ? t - prevT : 0;
    prevT = t;

    background(200, 55, 88); // sky blue
    grid.draw();

    // Drift + Noise Morph — call before drawing
    cloud.update(t, deltaT,
                 grid.UL.x, grid.LR.x,
                 true, true,                // drift + noise
                 grid.UL.y, grid.LR.y);

    cloud.drawOnGrid(grid);

    // Breathe — call after drawing
    cloud.breathe(breatheSin, breatheSin, t);
}

Source Code

Show / Hide swCloud.js source
/*
File: swCloud.js
Date: 2026-05-08
Author: klp + GitHub Copilot
Workspace: SketchWaveTNT2026-05-01-Stg9
Purpose: SWCloud class for SketchWaveJS

SWCloud represents a cumulus-style cloud shape built from N overlapping SWEllipse "puffs".
Each puff is a horizontally-elongated ellipse. Puffs are placed at random normalized offsets
(dx, dy each in −1..+1) around the cloud center, scaled by spreadH × puffWidth and
spreadV × puffHeight. Each puff also gets a random size scale (0.70–1.00).
The offsets are stored so sliders can rescale the cloud without changing its random shape.

Animations:
  breathe(sinX, sinY, t)  — each puff breathes independently with a configurable
                            phase stagger (seconds) between adjacent puffs.
  update(t, deltaT, ...)  — advances horizontal drift (with canvas wrap-around) and
                            applies per-puff Perlin-noise morphing so the cloud shape
                            shifts organically over time.

Constructor:
  new SWCloud(center, options)

  center  : SWPoint  — anchor of the cloud
  options :
    puffCount     : number  — number of ellipse puffs (default 5; min 1)
    puffWidth     : number  — base horizontal radius of puffs in user units (default 1.8)
    puffHeight    : number  — base vertical   radius of puffs in user units (default 1.0)
    spread        : number  — horizontal scatter scale; 0 = all puffs at center (default 1.0)
    spreadV       : number  — vertical   scatter scale (default 0.6)
    fillColor     : SWColor — fill color (default: undefined = no fill)
    strokeColor   : SWColor — border color (default: undefined = no border)
    strokeWeight  : number  — border thickness (default 1)
    showCenters   : boolean — show SWPoint dots at each puff center (default false)
    breatheStagger: number  — phase offset in seconds between adjacent puff breaths (default 0.3)
    driftSpeed    : number  — horizontal drift speed in user units/sec; + = rightward (default 0)
    noiseAmp      : number  — Perlin-noise morph amplitude in user units (default 0.3)
    noiseSpeed    : number  — Perlin-noise time scale (higher = faster morphing) (default 0.3)

Key methods:
  drawOnGrid(grid)              — draw all puffs in user/grid coordinates
  draw()                        — draw all puffs in screen (pixel) coordinates
  breathe(sinX, sinY, t)        — animate puff sizes with staggered phase
  update(t, deltaT, ...)        — advance drift and/or apply per-puff Perlin noise morph
  randomize()                   — generate a new set of random puff offsets and rebuild
  reset()                       — restore all factory-default properties and rebuild puffs
  setShowCenters(bool)          — toggle SWPoint center dots on all puffs
  setPuffCount(n)               — change puff count and rebuild (new random offsets)
  setPuffWidth(w)               — change base puff radiusX and rebuild
  setPuffHeight(h)              — change base puff radiusY and rebuild
  setSpread(s)                  — change horizontal scatter scale and rebuild
  setSpreadV(v)                 — change vertical   scatter scale and rebuild
  setCenter(x, y)               — reposition cloud anchor and rebuild
  setFillColor(col)             — update fill color on all puffs (no rebuild)
  setStrokeColor(col)           — update border color on all puffs (no rebuild)
  setStrokeWeight(w)            — update border weight on all puffs (no rebuild)
  setBreatheStagger(s)          — update phase stagger between puffs (no rebuild)
  setDriftSpeed(v)              — update horizontal drift speed (no rebuild)
  setNoiseAmp(v)                — update Perlin-noise morph amplitude (no rebuild)
  setNoiseSpeed(v)              — update Perlin-noise time scale (no rebuild)

Getters:
  width   — approximate total width of the cloud in user coords
  height  — approximate total height of the cloud in user coords

Dependencies: p5.js, SWColor, SWPoint, SWGrid, SWEllipse
*/

console.log("[swCloud.js] SWCloud class loaded.");

class SWCloud {

    /**
     * @param {SWPoint} center       - Anchor point (baseline of cloud center)
     * @param {Object}  [options={}]
     *   puffCount     : number  — number of ellipse puffs (default 5)
     *   puffWidth     : number  — base radiusX of each puff (user units, default 1.8)
     *   puffHeight    : number  — base radiusY of each puff (user units, default 1.0)
     *   spread        : number  — spacing multiplier (default 1.0)
     *   fillColor     : SWColor — fill color
     *   strokeColor   : SWColor — border color
     *   strokeWeight  : number  — border thickness (default 1)
     *   showCenters   : boolean — show SWPoint dots at puff centers (default false)
     *   breatheStagger: number  — phase offset (seconds) between adjacent puff breaths (default 0.3)
     *   driftSpeed    : number  — horizontal drift speed (user units/sec); + = rightward (default 0)
     *   noiseAmp      : number  — Perlin-noise morph amplitude in user units (default 0.3)
     *   noiseSpeed    : number  — Perlin-noise time scale (higher = faster morphing) (default 0.3)
     */
    constructor(center, options = {}) {
        this.center        = center;
        this.puffCount     = options.puffCount     !== undefined ? Math.max(1, Math.round(options.puffCount)) : 5;
        this.puffWidth     = options.puffWidth     !== undefined ? options.puffWidth     : 1.8;
        this.puffHeight    = options.puffHeight    !== undefined ? options.puffHeight    : 1.0;
        this.spread        = options.spread        !== undefined ? options.spread        : 1.0;
        this.spreadV       = options.spreadV       !== undefined ? options.spreadV       : 0.6;
        this.fillColor     = options.fillColor     ? SWColor.copy(options.fillColor)    : undefined;
        this.strokeColor   = options.strokeColor   ? SWColor.copy(options.strokeColor)  : undefined;
        this.strokeWeight  = options.strokeWeight  !== undefined ? options.strokeWeight  : 1;
        this.showCenters   = options.showCenters   !== undefined ? options.showCenters   : false;
        this.breatheStagger = options.breatheStagger !== undefined ? options.breatheStagger : 0.3;
        this.driftSpeed  = options.driftSpeed  !== undefined ? options.driftSpeed  : 0;
        this.noiseAmp    = options.noiseAmp    !== undefined ? options.noiseAmp    : 0.3;
        this.noiseSpeed  = options.noiseSpeed  !== undefined ? options.noiseSpeed  : 0.3;
        this.wrapGap     = options.wrapGap     !== undefined ? options.wrapGap     : 2.0;

        // Store originals for reset()
        this.originalCenter      = new SWPoint(center.x, center.y);
        this.originalPuffCount   = this.puffCount;
        this.originalPuffWidth   = this.puffWidth;
        this.originalPuffHeight  = this.puffHeight;
        this.originalSpread      = this.spread;
        this.originalSpreadV     = this.spreadV;
        this.originalDriftSpeed  = this.driftSpeed;
        this.originalNoiseAmp    = this.noiseAmp;
        this.originalNoiseSpeed  = this.noiseSpeed;
        this.originalWrapGap     = this.wrapGap;

        // Generate initial random offsets and store them for reset()
        this._offsets = [];
        this._generateOffsets(this.puffCount);
        this.originalOffsets = this._offsets.map(o => ({ dx: o.dx, dy: o.dy, size: o.size }));

        this._puffs = [];
        this._buildPuffs();
    }//end constructor

    // —— Internal: puff layout ———————————————————————

    /**
     * Builds the _puffs array from current properties.
     * Called by the constructor and by all setters that affect shape or layout.
     */
    _buildPuffs() {
        this._puffs = [];
        const N = this.puffCount;

        // Ensure random offsets exist and match current puff count
        if (!this._offsets || this._offsets.length !== N) {
            this._generateOffsets(N);
        }

        for (let i = 0; i < N; i++) {
            const off = this._offsets[i];

            // Puff center — random offset scaled by spread magnitudes and puff dimensions
            const px = this.center.x + off.dx * this.spread  * this.puffWidth;
            const py = this.center.y + off.dy * this.spreadV * this.puffHeight;

            // Per-puff size variation stored in the offset
            const rX = this.puffWidth  * off.size;
            const rY = this.puffHeight * off.size;

            const puffCenter = new SWPoint(px, py);
            const puff = new SWEllipse(
                puffCenter, rX, rY,
                this.fillColor  ? SWColor.copy(this.fillColor)  : undefined,
                {
                    strokeColor:  this.strokeColor ? SWColor.copy(this.strokeColor) : undefined,
                    strokeWeight: this.strokeWeight,
                    showCenter:   this.showCenters,
                }
            );
            this._puffs.push(puff);
        }
    }//end _buildPuffs

    /**
     * Generates N random normalized offsets for puff placement.
     * Each offset has dx/dy in −1..+1 (horizontal/vertical scatter) and
     * a random size scale in 0.70..1.00.
     * When N=1 the single puff is always placed at the center (dx=dy=0).
     * @param {number} n
     */
    _generateOffsets(n) {
        this._offsets = [];
        if (n === 1) {
            // Single puff always centered regardless of spread
            this._offsets.push({ dx: 0, dy: 0, size: 1.0 });
            return;
        }
        for (let i = 0; i < n; i++) {
            this._offsets.push({
                dx:   Math.random() * 2 - 1,        // −1 .. +1  horizontal scatter
                dy:   Math.random() * 2 - 1,        // −1 .. +1  vertical scatter
                size: 0.70 + Math.random() * 0.30,  // 0.70 .. 1.00  size variation
            });
        }
    }//end _generateOffsets

    // —— Getters ————————————————————————————————————

    /**
     * Approximate total width of the cloud in user coords
     * (from left edge of leftmost puff to right edge of rightmost puff).
     */
    get width() {
        if (this._puffs.length === 0) return 0;
        let minX = Infinity, maxX = -Infinity;
        for (const p of this._puffs) {
            minX = Math.min(minX, p.center.x - p.radiusX);
            maxX = Math.max(maxX, p.center.x + p.radiusX);
        }
        return maxX - minX;
    }//end width

    /**
     * Approximate total height of the cloud in user coords
     * (from bottom edge of edge puffs to top edge of center puff).
     */
    get height() {
        if (this._puffs.length === 0) return 0;
        let minY = Infinity, maxY = -Infinity;
        for (const p of this._puffs) {
            minY = Math.min(minY, p.center.y - p.radiusY);
            maxY = Math.max(maxY, p.center.y + p.radiusY);
        }
        return maxY - minY;
    }//end height

    // —— Setters ————————————————————————————————————

    /** Show or hide the SWPoint dot at each puff center. */
    setShowCenters(show = true) {
        this.showCenters = show;
        for (const p of this._puffs) p.setShowCenter(show);
    }//end setShowCenters

    /** Change the number of puffs and rebuild. Clamps to ≥ 1. */
    setPuffCount(n) {
        this.puffCount = Math.max(1, Math.round(n));
        this._buildPuffs();
    }//end setPuffCount

    /** Change the base puff radiusX (horizontal) and rebuild. */
    setPuffWidth(w) {
        this.puffWidth = w;
        this._buildPuffs();
    }//end setPuffWidth

    /** Change the base puff radiusY (vertical) and rebuild. */
    setPuffHeight(h) {
        this.puffHeight = h;
        this._buildPuffs();
    }//end setPuffHeight

    /** Change the horizontal scatter magnitude and rebuild. */
    setSpread(s) {
        this.spread = s;
        this._buildPuffs();
    }//end setSpread

    /** Change the vertical scatter magnitude and rebuild. */
    setSpreadV(v) {
        this.spreadV = v;
        this._buildPuffs();
    }//end setSpreadV

    /** Reposition the cloud anchor and rebuild all puffs around the new center. */
    setCenter(x, y) {
        this.center.x = x;
        this.center.y = y;
        this._buildPuffs();
    }//end setCenter

    /** Apply a new fill color to all puffs without rebuilding. */
    setFillColor(col) {
        this.fillColor = col ? SWColor.copy(col) : undefined;
        for (const p of this._puffs) {
            p.fillColor = this.fillColor ? SWColor.copy(this.fillColor) : undefined;
        }
    }//end setFillColor

    /** Apply a new stroke color to all puffs without rebuilding. */
    setStrokeColor(col) {
        this.strokeColor = col ? SWColor.copy(col) : undefined;
        for (const p of this._puffs) {
            p.strokeColor = this.strokeColor ? SWColor.copy(this.strokeColor) : undefined;
        }
    }//end setStrokeColor

    /** Apply a new stroke weight to all puffs without rebuilding. */
    setStrokeWeight(w) {
        this.strokeWeight = w;
        for (const p of this._puffs) p.strokeWeight = w;
    }//end setStrokeWeight

    /** Update the phase offset (seconds) between adjacent puff breaths. */
    setBreatheStagger(s) {
        this.breatheStagger = s;
    }//end setBreatheStagger

    /** Change the horizontal drift speed (user units / second; positive = rightward). */
    setDriftSpeed(v) {
        this.driftSpeed = v;
    }//end setDriftSpeed

    /** Change the Perlin-noise morph amplitude (user units; 0 = no displacement). */
    setNoiseAmp(v) {
        this.noiseAmp = v;
    }//end setNoiseAmp

    /** Change how fast the Perlin-noise pattern evolves (higher = faster morphing). */
    setNoiseSpeed(v) {
        this.noiseSpeed = v;
    }//end setNoiseSpeed

    /** Change how far (user units) past the canvas edge the cloud travels before wrapping back. 0 = instant reappear. */
    setWrapGap(v) {
        this.wrapGap = Math.max(0, v);
    }//end setWrapGap

    /**
     * Generates a completely new set of random puff offsets and rebuilds.
     * Use this to get a fresh random cloud shape without changing any parameters.
     */
    randomize() {
        this._generateOffsets(this.puffCount);
        this._buildPuffs();
    }//end randomize

    /**
     * Advances cloud drift and applies per-puff Perlin-noise morphing each frame.
     * Call once per frame from draw() when drift or noise-morph is active.
     * Updates puff center positions in-place — does not rebuild SWEllipse objects.
     *
     * @param {number}  t              - elapsed time in seconds
     * @param {number}  deltaT         - seconds since last frame
     * @param {number}  [wrapLeft=-15] - left wrap boundary in user coords
     * @param {number}  [wrapRight=15] - right wrap boundary in user coords
     * @param {boolean} [doDrift=true] - advance center.x and wrap horizontally
     * @param {boolean} [doNoise=true] - apply per-puff Perlin noise displacement
     */
    update(t, deltaT, wrapLeft = -15, wrapRight = 15, doDrift = true, doNoise = true, wrapTop = 10, wrapBottom = -10) {
        // 1. Advance drift — move center.x and wrap when cloud exits the canvas
        if (doDrift) {
            this.center.x += this.driftSpeed * deltaT;
            // wrapGap: user-controlled distance past the canvas edge before wrapping
            const margin = this.wrapGap;
            // Vertical nudge applied on every wrap: ±1.5× puffHeight, clamped to canvas
            const maxNudge = this.puffHeight * 1.5;
            const nudge    = (Math.random() * 2 - 1) * maxNudge;
            const halfH    = this.puffHeight * Math.max(this.spreadV, 0.5) * (this.puffCount * 0.3 + 1);
            if (this.driftSpeed >= 0 && this.center.x - margin > wrapRight) {
                this.center.x  = wrapLeft - margin;
                this.center.y  = Math.min(wrapTop    - halfH,
                                 Math.max(wrapBottom + halfH, this.center.y + nudge));
            } else if (this.driftSpeed < 0 && this.center.x + margin < wrapLeft) {
                this.center.x  = wrapRight + margin;
                this.center.y  = Math.min(wrapTop    - halfH,
                                 Math.max(wrapBottom + halfH, this.center.y + nudge));
            }
        }

        // 2. Reposition each puff: base offset from _offsets, plus optional Perlin noise
        for (let i = 0; i < this._puffs.length; i++) {
            const off   = this._offsets[i];
            const baseX = this.center.x + off.dx * this.spread  * this.puffWidth;
            const baseY = this.center.y + off.dy * this.spreadV * this.puffHeight;
            if (doNoise) {
                // Spatially distinct noise per puff via prime-number multipliers on i
                const nx = (noise(i * 1.73,      t * this.noiseSpeed) * 2 - 1) * this.noiseAmp;
                const ny = (noise(i * 3.17 + 50, t * this.noiseSpeed) * 2 - 1) * this.noiseAmp * 0.5;
                this._puffs[i].center.x = baseX + nx;
                this._puffs[i].center.y = baseY + ny;
            } else {
                this._puffs[i].center.x = baseX;
                this._puffs[i].center.y = baseY;
            }
        }
    }//end update

    // —— Rendering ————————————————————————————————————

    /**
     * Draws all puffs in user/grid coordinates via the given SWGrid.
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        for (const p of this._puffs) p.drawOnGrid(grid);
    }//end drawOnGrid

    /**
     * Draws all puffs in screen (pixel) coordinates.
     * radiusX / radiusY are treated as pixel values.
     */
    draw() {
        for (const p of this._puffs) p.draw();
    }//end draw

    // —— Animation ————————————————————————————————————

    /**
     * Scales each puff's radiusX and/or radiusY using SWSinusoid instances.
     * Adjacent puffs breathe with a configurable phase stagger, producing
     * an organic, billowing cloud effect.
     *
     * @param {SWSinusoid|null} sinusoidX — horizontal scale driver (or null)
     * @param {SWSinusoid|null} sinusoidY — vertical   scale driver (or null)
     * @param {number}          t         — elapsed time in seconds
     */
    breathe(sinusoidX, sinusoidY, t) {
        for (let i = 0; i < this._puffs.length; i++) {
            this._puffs[i].breathe(sinusoidX, sinusoidY, t + i * this.breatheStagger);
        }
    }//end breathe

    /**
     * Resets the cloud to its original construction parameters and rebuilds all puffs.
     * Also restores the center position.
     */
    reset() {
        this.center.x   = this.originalCenter.x;
        this.center.y   = this.originalCenter.y;
        this.puffCount  = this.originalPuffCount;
        this.puffWidth  = this.originalPuffWidth;
        this.puffHeight = this.originalPuffHeight;
        this.spread     = this.originalSpread;
        this.spreadV    = this.originalSpreadV;
        this.driftSpeed = this.originalDriftSpeed;
        this.noiseAmp   = this.originalNoiseAmp;
        this.noiseSpeed = this.originalNoiseSpeed;
        this.wrapGap    = this.originalWrapGap;
        // Restore the original random offsets so reset returns the exact original cloud shape
        this._offsets   = this.originalOffsets.map(o => ({ dx: o.dx, dy: o.dy, size: o.size }));
        this._buildPuffs();
    }//end reset

}//end class SWCloud