❤️ SWHeart Reference

A SketchWave parametric class for representing a heart defined by x = a·sin³(t),  y = b·cos(t) + c·cos(2t) + d·cos(3t) + e·cos(4t)

Back to SWHeart Demo 📐 About Parametric Equations

Quick Reference

SWHeart is a SketchWave parametric class that represents a heart shape defined by the equations x(t) = a·sin³(t) and y(t) = b·cos(t) + c·cos(2t) + d·cos(3t) + e·cos(4t) for t ∈ [0, 2π]. The curve is naturally closed (t returns to its starting point) and is drawn as a filled and/or stroked polygon sampled at SAMPLE_COUNT = 200 points. SWHeart is not a composite class; all drawing, animation, and styling is handled directly.

  • Design Pattern: Parametric curve (not composition or inheritance)
  • Equations: x = a·sin³(t),   y = b·cos(t) + c·cos(2t) + d·cos(3t) + e·cos(4t)
  • Internal Structure: Cartesian coordinates sampled at SAMPLE_COUNT = 200 points around the closed curve
  • Dependencies: SWPoint, SWColor, SWGrid, p5.js
  • Key Features: Five shape coefficients (a–e), scale, fill & stroke colors with independent alpha, spin animation, beat (heartbeat) animation, draggable center
  • Common Uses: Valentine art, love-themed graphics, mathematical curve illustration, interactive shape exploration

Overview

The SWHeart class draws a heart shape as a closed polygon. The heart is fully defined by five coefficients, a center point, and a scale factor. In Cartesian coordinates, with the parametric variable t ranging from 0 to 2π:

x(t) = a · sin³(t)
y(t) = b · cos(t)  +  c · cos(2t)  +  d · cos(3t)  +  e · cos(4t)

user_x = center.x + scale · x(t)
user_y = center.y + scale · y(t)
Suggested 'good heart' defaults: a = 16, b = 13, c = −5, d = −2, e = −1, scale = 0.5
At these values the heart spans ±8 grid units wide and fits neatly within a standard [−10, 10] × [−10, 10] SWGrid.

Orientation: Two lobes sit at the top (positive y in user space), with the sharp tip at the bottom. The parametric origin (0, 0) — mapped to center — lies at roughly mid-height of the heart.

Fill Semantics

The heart curve is a naturally closed shape (at t = 0 and t = 2π the curve returns to exactly the same point). When a fill color is set, p5.js fills the entire interior of the closed polygon. The heart is drawn in two passes: a fill pass (no stroke) followed by a stroke pass (no fill), so the outline sits precisely on top of the filled region with no color-mixing artifacts.

Rotation

SWHeart supports two layers of rotation, both applied about the center point:

  • rotationDeg — Static base rotation set by setRotation(). Persists across frames; survives reset().
  • rotation — Accumulated rotation incremented by rotate(). Starts at 0; cleared by reset().

Effective rotation = rotationDeg + rotation. All angles are CCW positive (standard math convention). The y-flip for screen coordinates is handled internally.

Beat Animation

The beat animation simulates a heartbeat by sinusoidally oscillating the scale property each frame via setScale(). The formula is:

beatScale = baseScale + depth · sin(2π · speed · t)

At speed = 1.2 Hz this produces approximately 72 BPM — a normal resting heart rate. Beat is implemented entirely in the sketch layer; the SWHeart class only needs setScale() called each frame.

Key Capabilities

  • Parametric Heart Curve: Mathematically precise; the five coefficients independently control width, lobe height, cleft depth, tip sharpness, and fine detail
  • Scale: Maps parametric coordinates to grid units; easily resizes the heart without changing shape
  • Spin Animation: Rotate continuously about the center using rotate()
  • Beat Animation: Oscillate scale via setScale() to simulate a heartbeat at any BPM
  • Fill & Stroke Colors: Independent color pickers and alpha channels for stroke (outline) and fill (interior)
  • Draggable Center: Move the entire heart by repositioning the center SWPoint
  • Dual Coordinate Systems: Draw in screen pixels (draw()) or grid user coordinates (drawOnGrid())

Constructor

new SWHeart(center, a, b, c, d, e, fillColor, strokeColor, thickness, scale, rotationDeg)

Creates a new SWHeart instance. All constructor values are saved as originals for reset().

Parameters
ParameterTypeDefaultDescription
center SWPoint required The origin of the heart in user (grid) coordinates. All parametric displacements are added to this point.
a number 16 Width coefficient for the sin³(t) term. Controls how wide the heart is; the x-extent is ±a·scale grid units.
b number 13 Primary cosine coefficient. Dominates the overall height of the top lobes.
c number −5 Second cosine coefficient. Negative values deepen the cleft between the two lobes.
d number −2 Third cosine coefficient. Negative values help sharpen the bottom tip.
e number −1 Fourth cosine coefficient. Fine-detail sculpting; subtle at default value.
fillColor SWColor undefined Fill color for the interior. Pass undefined for no fill (stroke-only outline).
strokeColor SWColor undefined Stroke color for the outline. Pass undefined for no stroke (fill-only silhouette).
thickness number 2 Stroke weight in pixels.
scale number 0.5 Maps parametric coordinates to grid units. Default 0.5 fits the standard heart within a [−10, 10] grid.
rotationDeg number 0 Static base rotation in CCW degrees. Survives reset().
Examples
// Classic red heart — default 'good heart' coefficients
const fill   = new SWColor(350, 70, 87, 90,  "heartFill");   // #dd2255
const stroke = new SWColor(350, 100, 53, 100, "heartStroke"); // #880022
const center = new SWPoint(0, 0, undefined, 8,
                   new SWColor(0, 0, 20, 100, "center"));

const heart = new SWHeart(center, 16, 13, -5, -2, -1,
                          fill, stroke, 2, 0.5, 0);
// Silhouette only — fill, no stroke
const fill  = new SWColor(350, 80, 70, 100, "f");
const heart = new SWHeart(center, 16, 13, -5, -2, -1,
                          fill, undefined, 0, 0.5, 0);
// Using SWColor.fromHex() — useful with color pickers
const fill   = SWColor.fromHex('#dd2255', 90,  'heartFill');
const stroke = SWColor.fromHex('#880022', 100, 'heartStroke');
const heart  = new SWHeart(center, 16, 13, -5, -2, -1,
                           fill, stroke, 2, 0.5, 0);

Properties

center SWPoint

The heart's origin in user (grid) coordinates. All parametric displacements are added to this point. Moving center.x or center.y repositions the entire heart without rebuilding it.

heart.center.x = 3; heart.center.y = -2;
a number

Width coefficient for the sin³(t) term. Controls the horizontal extent of the heart; the x-range spans from −a·scale to +a·scale grid units. Use setA() to change it.

heart.setA(8); // half-width heart
heart.setA(24); // wider heart
b number

Primary cosine coefficient. Dominates the overall y-range; increasing b raises the top lobes. Use setB() to change it.

heart.setB(20); // taller lobes
c number

Second cosine coefficient. Negative values deepen the cleft between the two lobes; values near zero produce a rounder top. Use setC() to change it.

heart.setC(-12); // deep cleft
heart.setC(0); // round top
d number

Third cosine coefficient. Interacts with b and c to shape the tip region. Use setD() to change it.

heart.setD(-5); // sharper lower tip
e number

Fourth cosine coefficient. Adds fine-detail sculpting. Subtle at the default value of −1. Use setE() to change it.

heart.setE(-3); // more pronounced detail
scale number

Maps parametric coordinates to grid units. At scale = 0.5 the default heart spans ±8 units wide, fitting in a [−10, 10] grid. Use setScale() to change it; minimum clamped to 0.01.

heart.setScale(0.3); // smaller
heart.setScale(0.8); // larger
rotationDeg number

Static base rotation in CCW degrees. Set by setRotation(); survives reset(). Added to the accumulated rotation to produce the effective drawing rotation. In the demo, the Rotation (°) slider covers 0–360° and sets this value directly. Useful for orienting the heart before any spin animation begins — Spin accumulates on top of this base angle.

heart.setRotation(45); // tilt 45° CCW
heart.setRotation(180); // flip upside-down
heart.setRotation(0); // restore upright
rotation number

Accumulated rotation in degrees, incremented each frame by rotate(). Starts at 0; cleared by reset().

// Cleared automatically by reset(); read-only in normal use
SWHeart.SAMPLE_COUNT static

Number of polygon sample points around the full t ∈ [0, 2π] curve (default: 200). Higher values produce smoother curves; lower values reveal the polygon facets. Can be set at any time.

SWHeart.SAMPLE_COUNT = 24; // faceted, angular look
SWHeart.SAMPLE_COUNT = 400; // very smooth

Methods

Drawing Methods

draw()

Draws the heart in raw screen (pixel) coordinates. The center SWPoint is interpreted as screen pixels. Prefer drawOnGrid() for standard canvas use with an SWGrid.

function draw() {
    background(220);
    heart.draw();
}
drawOnGrid(grid)

Draws the heart mapped through the given SWGrid's coordinate system. This is the standard method; the grid handles converting user-space coordinates to screen pixels and the y-flip (math up → screen down).

function draw() {
    background(220);
    grid.draw();
    heart.drawOnGrid(grid);
}

Rotation Animation

rotate(deltaAngle)

Spins the heart about its center by deltaAngle degrees (CCW+, CW−). Accumulates into this.rotation. Call once per frame in draw() before calling drawOnGrid().

// Spin at 45°/second
const SPIN_SPEED = 45;
let prevT = 0;

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

    heart.rotate(SPIN_SPEED * deltaT);  // call BEFORE drawOnGrid
    heart.drawOnGrid(grid);
}

Beat Animation

Beat — sinusoidal scale oscillation

The beat effect simulates a heartbeat by oscillating scale sinusoidally using setScale() each frame:

beatScale = baseScale + depth · sin(2π · speed · t)

baseScale is the steady-state value (typically from a slider). depth (Δscale) is the amplitude of oscillation. speed is the frequency in Hz (cycles per second). At 1.2 Hz this is approximately 72 BPM. The value is clamped to a minimum of 0.01 so the heart never fully collapses. Beat and Spin can run simultaneously.

// Heartbeat at 1.2 Hz (≈ 72 BPM)
const BEAT_SPEED = 1.2;  // Hz
const BEAT_DEPTH = 0.08; // Δscale
let baseScale = 0.5;

function draw() {
    const t = millis() / 1000;
    const beatScale = baseScale + BEAT_DEPTH * sin(TWO_PI * BEAT_SPEED * t);
    heart.setScale(max(0.01, beatScale));
    heart.drawOnGrid(grid);
}

Setter Methods

setA(a)   setB(b)   setC(c)   setD(d)   setE(e)

Update the individual cosine/cubic coefficients. Take effect immediately on the next draw call.

heart.setA(12); heart.setC(-8);
setScale(s)

Sets the scale factor (minimum 0.01). Use this each frame for the beat animation.

heart.setScale(0.7);
setStrokeColor(sc)   setFillColor(fc)

Sets the stroke or fill color. Pass an SWColor instance or undefined to remove the color.

heart.setFillColor(SWColor.fromHex('#ff6688', 80, 'f'));
heart.setStrokeColor(undefined); // remove outline
setFillAlpha(alpha)   setStrokeAlpha(alpha)

Sets the fill or stroke alpha (0–100) and rebuilds the p5 color object. Requires an existing fill/stroke color to be set first.

heart.setFillAlpha(60); // 60% opacity fill
heart.setStrokeAlpha(100); // fully opaque stroke
setStrokeWeight(w)

Sets the stroke thickness in pixels.

heart.setStrokeWeight(4);
setRotation(deg)

Sets the static base rotation in CCW degrees. Does not affect the accumulated rotation.

heart.setRotation(90); // rotates the heart 90° CCW

Reset & Utility Methods

reset()

Restores all animated and slider-driven properties to their original constructor values. Clears accumulated spin rotation. Does not move the center position.

heart.reset(); // back to factory defaults
static SWHeart.copy(other)

Creates a deep copy of the given SWHeart, preserving all current and original state including rotation accumulation.

const copy = SWHeart.copy(heart);
toString()

Returns a human-readable string describing the heart's current state.

console.log(heart.toString());
// "SWHeart(center=SWPoint(x:0, y:0), a=16, b=13, c=-5, d=-2, e=-1, scale=0.500, rotationDeg=0.0, rotation=0.0)"

Code Examples

Minimal sketch (heart on a grid)

let grid, heart;

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

    grid   = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });
    const fill   = SWColor.fromHex('#dd2255', 90,  'heartFill');
    const stroke = SWColor.fromHex('#880022', 100, 'heartStroke');
    const center = new SWPoint(0, 0);
    heart  = new SWHeart(center, 16, 13, -5, -2, -1, fill, stroke, 2, 0.5, 0);
}

function draw() {
    background(240);
    grid.draw();
    heart.drawOnGrid(grid);
    grid.updateScreenBounds();
}

Spinning heart

let prevT = 0;
const SPIN_SPEED = 60; // degrees per second

function setup() { /* ... create grid and heart ... */ }

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

    background(240);
    grid.draw();
    heart.rotate(SPIN_SPEED * deltaT);   // spin BEFORE drawing
    heart.drawOnGrid(grid);
    grid.updateScreenBounds();
}

Heartbeat animation (beat)

const BEAT_SPEED = 1.2;  // Hz ≈ 72 BPM
const BEAT_DEPTH = 0.08; // ±0.08 scale units
const BASE_SCALE = 0.5;

function draw() {
    const t = millis() / 1000;
    const beatScale = BASE_SCALE + BEAT_DEPTH * sin(TWO_PI * BEAT_SPEED * t);
    heart.setScale(max(0.01, beatScale));

    background(240);
    grid.draw();
    heart.drawOnGrid(grid);
    grid.updateScreenBounds();
}

Spin + Beat running simultaneously

let prevT = 0;
const SPIN_SPEED = 30;
const BEAT_SPEED = 1.2;
const BEAT_DEPTH = 0.08;
const BASE_SCALE = 0.5;

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

    // Spin (rotation accumulates)
    heart.rotate(SPIN_SPEED * deltaT);

    // Beat (scale oscillates around base)
    const beatScale = BASE_SCALE + BEAT_DEPTH * sin(TWO_PI * BEAT_SPEED * t);
    heart.setScale(max(0.01, beatScale));

    background(240);
    grid.draw();
    heart.drawOnGrid(grid);
    grid.updateScreenBounds();
}

Using a color picker with SWColor.fromHex()

// In your HTML:
// <input type="color" id="fillPicker" value="#dd2255">
// <input type="range" id="alphaSlider" min="0" max="100" value="90">

const picker = document.getElementById('fillPicker');
const alpha  = document.getElementById('alphaSlider');

picker.addEventListener('input', () => {
    const col = SWColor.fromHex(picker.value, Number(alpha.value), 'heartFill');
    heart.setFillColor(col);
});

alpha.addEventListener('input', () => {
    const col = SWColor.fromHex(picker.value, Number(alpha.value), 'heartFill');
    heart.setFillColor(col);
});

Required script tags (in dependency order)

<script src="https://cdn.jsdelivr.net/npm/p5@1.6.0/lib/p5.js"></script>

<!-- SketchWaveJS classes in dependency order -->
<script src="../shapeClasses/swColor.js"></script>
<script src="../shapeClasses/swPoint.js"></script>
<script src="../shapeClasses/swGrid.js"></script>
<script src="../shapeClasses/swHeart.js"></script>

<!-- Your sketch -->
<script src="../sketches/yourSketch.js"></script>

Design Notes

  • Coefficient interplay: The five coefficients are not independent in their visual effect — they all contribute to the y-curve at every point. Start from the recommended defaults (a=16, b=13, c=−5, d=−2, e=−1) and adjust one at a time to understand each coefficient's role. Moving c toward zero flattens the cleft; making b larger amplifies the lobes.
  • Scale vs. a: Changing a stretches only the x-dimension (since x = a·sin³(t)), creating a narrow or wide heart. Changing scale uniformly scales both x and y, preserving the shape's proportions. Use a to adjust the aspect ratio; use scale to resize uniformly.
  • SAMPLE_COUNT controls smoothness: The default of 200 points produces a very smooth curve with no visible faceting. Reducing to 20–30 creates an interesting angular, crystalline look. Unlike the spiral, the heart is a single closed curve so there is no per-revolution factor.
  • Fill color semantics: Because the heart is a single closed convex-ish curve, p5.js fills the full interior naturally. Unlike the spiral, there are no layering effects to worry about. A solid fill with 80–100% opacity gives a clean silhouette.
  • Rotation is applied in Cartesian space: The rotation is applied to the computed (x, y) displacement from center before mapping to screen coordinates. This correctly rotates the heart about its center in user space.
  • reset() does not move the center: This allows the center to be dragged and repositioned interactively without losing the position on reset. The center SWPoint's strokeWeight can be set to 0 to hide the center dot.
  • Rotation slider vs. Spin: The demo's Rotation (°) slider (0–360) sets rotationDeg — a static base orientation that persists across frames. Spin then accumulates on top of it. At factory reset both return to 0. For a code-only setup, you can pass any CCW angle to the constructor or call setRotation() at any time; there is no enforced 0–360 limit in the class itself.
  • Beat vs. Breathe terminology: This class uses "beat" rather than "breathe" to match the heart metaphor. The underlying mechanism is identical: sinusoidal oscillation of a size parameter via a setter called once per frame.

Source Code

The complete SWHeart class implementation:

Show/Hide Source Code
/*
File: swHeart.js
Date: 2026-04-27
Author: klp
App:  SketchWaveTNT2026-04-21-Stg8
Purpose: SWHeart class for SketchWaveJS

SWHeart represents a heart shape defined by the parametric equations:

  x(t) = a · sin³(t)
  y(t) = b · cos(t) + c · cos(2t) + d · cos(3t) + e · cos(4t)

for t ∈ [0, 2π], scaled by 'scale' and offset by 'center':

  user_x = center.x + scale · x(t)
  user_y = center.y + scale · y(t)

  center (SWPoint) — origin of the heart in user (grid) coordinates
  a (number)       — width coefficient; sin³(t) gives the x-curve its
                     characteristic smooth lobe shape
  b, c, d, e       — cosine coefficients that together shape the y-curve;
                     their interplay produces the two top lobes and the sharp
                     bottom tip of the heart
  scale (number)   — maps parametric coordinates to grid units

Suggested 'good heart' defaults:
  a=16, b=13, c=−5, d=−2, e=−1, scale=0.5

At these defaults the heart spans approximately ±8 grid units wide and
fits comfortably within a standard [−10, 10] × [−10, 10] SWGrid.

Orientation (at default values):
  Two lobes sit at the top (positive y in user space), the sharp tip at
  the bottom.  The parametric origin (0, 0) lies at roughly mid-height.

Beat animation:
  Oscillate 'scale' sinusoidally to simulate a heartbeat:
    beatScale = baseScale + depth · sin(2π · speed · t)
  At speed = 1.2 Hz this is approximately 72 BPM — a normal resting heart
  rate.  Implemented in the sketch layer via setScale().

Rotation:
  rotationDeg — static base rotation (CCW degrees), set by setRotation().
                Persists across frames; survives reset().
  rotation    — accumulated rotation (degrees), incremented by rotate().
                Starts at 0; reset() returns it to 0.
  Effective rotation = rotationDeg + rotation.  All rotation is CCW positive.
  All rotation is applied about the CENTER point.

Angle convention (same as all SketchWaveJS classes):
  User space:  CCW positive, y increases upward (standard math/Cartesian).
  p5 screen:   CW  positive, y increases downward.
  SWHeart handles the y-flip internally; always pass CCW degrees.

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

console.log("[swHeart.js] SWHeart class loaded.");

class SWHeart {

    static SAMPLE_COUNT = 200;  // sample points around the full t ∈ [0, 2π] curve

    /**
     * @param {SWPoint} center          - Center of the heart in user (grid) coordinates
     * @param {number}  [a=16]          - Width coefficient (sin³ term)
     * @param {number}  [b=13]          - Primary cosine coefficient
     * @param {number}  [c=-5]          - Second cosine coefficient
     * @param {number}  [d=-2]          - Third cosine coefficient
     * @param {number}  [e=-1]          - Fourth cosine coefficient
     * @param {SWColor} [fillColor]     - Fill color (undefined = no fill)
     * @param {SWColor} [strokeColor]   - Stroke / border color (undefined = no stroke)
     * @param {number}  [thickness=2]   - Stroke weight in pixels
     * @param {number}  [scale=0.5]     - Maps parametric coords to grid units
     * @param {number}  [rotationDeg=0] - Static base rotation in CCW degrees
     */
    constructor(center, a = 16, b = 13, c = -5, d = -2, e = -1,
                fillColor = undefined, strokeColor = undefined,
                thickness = 2, scale = 0.5, rotationDeg = 0) {

        this.center      = center;
        this.a           = a;
        this.b           = b;
        this.c           = c;
        this.d           = d;
        this.e           = e;
        this.fillColor   = fillColor   ? SWColor.copy(fillColor)   : undefined;
        this.strokeColor = strokeColor ? SWColor.copy(strokeColor) : undefined;
        this.thickness   = thickness;
        this.scale       = scale;
        this.rotationDeg = rotationDeg;
        this.rotation    = 0;   // accumulated via rotate(); cleared by reset()

        // ── Originals for reset() ──────────────────────────────────────────────
        this.originalA           = a;
        this.originalB           = b;
        this.originalC           = c;
        this.originalD           = d;
        this.originalE           = e;
        this.originalFillColor   = fillColor   ? SWColor.copy(fillColor)   : undefined;
        this.originalStrokeColor = strokeColor ? SWColor.copy(strokeColor) : undefined;
        this.originalThickness   = thickness;
        this.originalScale       = scale;
        this.originalRotationDeg = rotationDeg;

    }//end constructor

    // ── Internal helpers ──────────────────────────────────────────────────────

    /** @returns {number} Total effective rotation in degrees. */
    _totalRotDeg() { return this.rotationDeg + this.rotation; }

    /**
     * Rotates a local math-space displacement (lx, ly) by totalRotation degrees (CCW+).
     * @returns {{ x: number, y: number }} rotated displacement in math-space
     */
    _rotateLocal(lx, ly) {
        const rad  = this._totalRotDeg() * Math.PI / 180;
        const cosR = Math.cos(rad);
        const sinR = Math.sin(rad);
        return {
            x: lx * cosR - ly * sinR,
            y: lx * sinR + ly * cosR,
        };
    }

    /**
     * Builds an array of { x, y } positions in user (math) space for t ∈ [0, 2π].
     * @returns {{ x: number, y: number }[]}
     */
    _buildUserPts() {
        const cx  = this.center.x;
        const cy  = this.center.y;
        const n   = SWHeart.SAMPLE_COUNT;
        const pts = [];

        for (let i = 0; i <= n; i++) {
            const t    = (i / n) * 2 * Math.PI;
            const sinT = Math.sin(t);
            const lx   = this.scale * this.a * sinT * sinT * sinT;
            const ly   = this.scale * (
                this.b * Math.cos(t) +
                this.c * Math.cos(2 * t) +
                this.d * Math.cos(3 * t) +
                this.e * Math.cos(4 * t)
            );
            const rot = this._rotateLocal(lx, ly);
            pts.push({ x: cx + rot.x, y: cy + rot.y });
        }
        return pts;
    }

    /**
     * Builds screen-space { x, y } points using the given SWGrid.
     * grid.userToScreen() handles the math-space ↔ screen y-flip.
     */
    _buildScreenPtsGrid(grid) {
        return this._buildUserPts().map(p => grid.userToScreen(p.x, p.y));
    }

    /**
     * Builds screen-space { x, y } points for draw() (no grid).
     * The math-space y-displacement is negated to produce correct screen-space y (down).
     */
    _buildScreenPtsDirect() {
        const cx  = this.center.x;
        const cy  = this.center.y;
        const n   = SWHeart.SAMPLE_COUNT;
        const pts = [];

        for (let i = 0; i <= n; i++) {
            const t    = (i / n) * 2 * Math.PI;
            const sinT = Math.sin(t);
            const lx   = this.scale * this.a * sinT * sinT * sinT;
            const ly   = this.scale * (
                this.b * Math.cos(t) +
                this.c * Math.cos(2 * t) +
                this.d * Math.cos(3 * t) +
                this.e * Math.cos(4 * t)
            );
            const rot = this._rotateLocal(lx, ly);
            pts.push({ x: cx + rot.x, y: cy - rot.y });   // y-flip for screen
        }
        return pts;
    }

    /**
     * Executes fill + stroke passes from pre-computed screen points.
     * The heart is always a closed shape.
     */
    _drawShape(screenPts) {
        // ── Fill pass ────────────────────────────────────────────────────────────
        if (this.fillColor && this.fillColor.col) {
            fill(this.fillColor.col);
            noStroke();
            beginShape();
            for (const sp of screenPts) vertex(sp.x, sp.y);
            endShape(CLOSE);
        }

        // ── Stroke pass ──────────────────────────────────────────────────────────
        if (this.strokeColor && this.strokeColor.col) {
            noFill();
            stroke(this.strokeColor.col);
            strokeWeight(this.thickness);
            beginShape();
            for (const sp of screenPts) vertex(sp.x, sp.y);
            endShape(CLOSE);
        }

        noStroke();
        noFill();
        strokeWeight(1);
    }

    // ── Drawing ───────────────────────────────────────────────────────────────

    /**
     * Draws the heart in raw screen (pixel) coordinates.
     * Prefer drawOnGrid() for standard canvas use.
     */
    draw() {
        const screenPts = this._buildScreenPtsDirect();
        this._drawShape(screenPts);
        if (this.center && this.center.draw) {
            this.center.draw(this.strokeColor);
        }
    }//end draw

    /**
     * Draws the heart mapped through the given SWGrid's coordinate system.
     * This is the standard drawing method to use in a p5.js draw() loop.
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        const screenPts = this._buildScreenPtsGrid(grid);
        this._drawShape(screenPts);
        if (this.center && this.center.drawOnGrid) {
            this.center.drawOnGrid(grid, this.strokeColor);
        }
    }//end drawOnGrid

    // ── Animation ─────────────────────────────────────────────────────────────

    /**
     * Spins the heart about its center by deltaAngle degrees (CCW+, CW−).
     * Accumulates into this.rotation.  Call once per frame in draw().
     * @param {number} deltaAngle  degrees per frame (typically speed × deltaT)
     */
    rotate(deltaAngle) { this.rotation += deltaAngle; }

    // ── Setters ───────────────────────────────────────────────────────────────

    setA(a)            { this.a          = a; }
    setB(b)            { this.b          = b; }
    setC(c)            { this.c          = c; }
    setD(d)            { this.d          = d; }
    setE(e)            { this.e          = e; }
    setScale(s)        { this.scale      = Math.max(0.01, s); }

    /** Sets the static base rotation in CCW degrees. Does not affect accumulated rotation. */
    setRotation(deg)   { this.rotationDeg = deg; }

    setFillColor(fc)   { this.fillColor   = fc ? SWColor.copy(fc)   : undefined; }
    setStrokeColor(sc) { this.strokeColor = sc ? SWColor.copy(sc)   : undefined; }
    setStrokeWeight(w) { this.thickness   = w; }

    /**
     * Sets the fill alpha (0–100) and rebuilds the p5 color object.
     * @param {number} alpha  0 = transparent, 100 = opaque
     */
    setFillAlpha(alpha) {
        if (this.fillColor) {
            this.fillColor.a   = Math.max(0, Math.min(100, alpha));
            this.fillColor.col = color(
                this.fillColor.h, this.fillColor.s,
                this.fillColor.b, this.fillColor.a
            );
        }
    }

    /**
     * Sets the stroke alpha (0–100) and rebuilds the p5 color object.
     * @param {number} alpha  0 = transparent, 100 = opaque
     */
    setStrokeAlpha(alpha) {
        if (this.strokeColor) {
            this.strokeColor.a   = Math.max(0, Math.min(100, alpha));
            this.strokeColor.col = color(
                this.strokeColor.h, this.strokeColor.s,
                this.strokeColor.b, this.strokeColor.a
            );
        }
    }

    // ── Reset & Utility ───────────────────────────────────────────────────────

    /**
     * Restores all animated/slider-driven properties to original constructor values.
     * Clears accumulated spin rotation.  Does NOT move the center position.
     */
    reset() {
        this.a           = this.originalA;
        this.b           = this.originalB;
        this.c           = this.originalC;
        this.d           = this.originalD;
        this.e           = this.originalE;
        this.scale       = this.originalScale;
        this.rotationDeg = this.originalRotationDeg;
        this.rotation    = 0;
        this.thickness   = this.originalThickness;
        this.fillColor   = this.originalFillColor
            ? SWColor.copy(this.originalFillColor)   : undefined;
        this.strokeColor = this.originalStrokeColor
            ? SWColor.copy(this.originalStrokeColor) : undefined;
    }//end reset

    /**
     * Creates a deep copy of the given SWHeart, preserving all current and original state.
     * @param {SWHeart} other
     * @returns {SWHeart}
     */
    static copy(other) {
        const c = new SWHeart(
            SWPoint.copy(other.center),
            other.originalA, other.originalB, other.originalC,
            other.originalD, other.originalE,
            other.originalFillColor, other.originalStrokeColor,
            other.originalThickness, other.originalScale,
            other.originalRotationDeg
        );
        c.a           = other.a;
        c.b           = other.b;
        c.c           = other.c;
        c.d           = other.d;
        c.e           = other.e;
        c.scale       = other.scale;
        c.rotationDeg = other.rotationDeg;
        c.rotation    = other.rotation;
        return c;
    }//end copy

    toString() {
        return `SWHeart(center=${this.center}, a=${this.a}, b=${this.b}, c=${this.c}, ` +
               `d=${this.d}, e=${this.e}, scale=${this.scale.toFixed(3)}, ` +
               `rotationDeg=${this.rotationDeg.toFixed(1)}, rotation=${this.rotation.toFixed(1)})`;
    }

}//end SWHeart class