★ SWStar Class Reference

Regular n-Pointed Star with Independent Breathing & Spinning — SketchWaveJS Stage

Overview

SWStar is a standalone SketchWaveJS class for drawing a regular n-pointed star on a p5.js canvas. A regular star is defined by a center point, an outer circumradius (distance from center to each tip), an inner radius (distance from center to each valley between tips), and a number of points n ≥ 2.

The key design innovation over SWRegularPolygon is independent breathing: the outer radius and inner radius are each driven by their own SWSinusoid, so tips and valleys can pulse at completely different rates. Setting both to the same sinusoid produces uniform pulsing; different sinusoids create layered, organic animation.

FeatureDetails
Extends none (standalone)
Drawing beginShape / vertex / endShape(CLOSE) (sharp) or quadratic-bezier chamfer on tips only (rounded)
Animation Independent outer & inner breathing via two SWSinusoids, rotation via _animRotDeg
Vertex layout 2n vertices: outer tips at even indices (0,2,4…), inner valleys at odd indices (1,3,5…)
First vertex Top outer tip at −90° before rotation — first point always at 12 o'clock
Tip rounding Quadratic-bezier chamfer applied to outer tips only; inner valleys remain sharp
Draggable Yes, via centerContainsPoint()

Dependency Chain

p5.js → SWColor → SWPoint → SWGrid → SWSinusoid → SWStar

Constructor

new SWStar(center, outerRadius, innerRadius, numPoints, fillColor, options)
ParameterTypeDescription
center SWPoint Center of the star in user coordinates
outerRadius number Distance from center to each tip (user units)
innerRadius number Distance from center to each valley (user units); must be < outerRadius for a recognizable star
numPoints number Number of star points; clamped to ≥ 2 and rounded to integer
fillColor SWColor Interior fill color
options Object See table below

Options Object

KeyTypeDefaultDescription
tipRadius number 0 0 = sharp tips; >0 activates quadratic-bezier chamfering on outer tips only
strokeColor SWColor Border color (omit for no border)
strokeWeight number 2 Border thickness in pixels
showCenter boolean false Draw the center SWPoint dot
showTips boolean false Draw a small dot at each outer tip
showInner boolean false Draw a small dot at each inner valley
rotation number 0 Initial static rotation in degrees CCW
const fill   = SWColor.fromHex("#fbbf24", "gold");
fill.setAlphaTo(70);
const stroke = fill.createDarkerColor(0.75);
stroke.setAlphaTo(100);

const star = new SWStar(
    new SWPoint(0, 0),   // center at origin
    6,                   // outer radius (tips)
    2.5,                 // inner radius (valleys)
    5,                   // 5-pointed star
    fill,
    {
        tipRadius:    0,
        strokeColor:  stroke,
        strokeWeight: 3,
        showCenter:   false,
        rotation:     0
    }
);

Properties

Instance Properties

center : SWPoint
Center point in user coordinates. Draggable at runtime when animations are stopped.
outerRadius : number
Distance from center to each outer tip in user units. The actual drawn radius is outerRadius × _scaleOuter. Change at runtime to resize tips; call reset() to revert.
innerRadius : number
Distance from center to each inner valley in user units. The actual drawn inner radius is innerRadius × _scaleInner. When innerRadius approaches outerRadius, the star becomes a regular polygon.
numPoints : number (integer ≥ 2)
Number of star points. Change at runtime — the star redraws immediately with the new point count. Note: changing numPoints does not rebuild center style; for a clean rebuild call buildStar().
tipRadius : number
Tip rounding radius in user units. 0 = sharp tips; any positive value activates quadratic-bezier chamfering on the outer vertices only. Inner valleys always remain sharp. Scales with the grid: the pixel chamfer = tipRadius × grid.xScale.
rotation : number (degrees)
Static rotation applied before any animation rotation. CCW positive. The first outer tip is placed at the top (−90°) before this is applied.
fillColor : SWColor  |  strokeColor : SWColor  |  strokeWeight : number
Appearance properties — can be changed at any time and take effect on the next drawOnGrid() call.

Animation State (read only in practice)

_scaleOuter : number
Current breathing scale for the outer radius. Set by breathe(), transform(), or directly in the sketch's draw loop. Minimum effective value 0.1.
_scaleInner : number
Current breathing scale for the inner radius. Independent of _scaleOuter — the two can be at completely different values simultaneously.
_animRotDeg : number
Cumulative animation rotation in degrees CCW. Set by rotateAboutCenter() and transform(). Total displayed rotation = rotation + _animRotDeg.

Geometric Properties (Getters)

All getters reflect the live animated state — they use currentOuterRadius = outerRadius × _scaleOuter and currentInnerRadius = innerRadius × _scaleInner.

PropertyFormulaNotes
currentOuterRadius outerRadius × _scaleOuter Live outer tip reach
currentInnerRadius innerRadius × _scaleInner Live valley depth
tipLength currentOuterRadius − currentInnerRadius Radial length of each star arm
area Shoelace formula on the 2n vertices Exact for any outer/inner ratio; animates live
perimeter 2n · arm, where arm = √(R² + r² − 2Rr·cos(π/n)) Total length of the 2n arm segments (cosine rule)

Drawing Methods

drawOnGrid(grid)
Method

The primary draw method. Computes 2n vertex positions in user coordinates, maps them to screen pixels via grid.userToScreen(), then draws the star path in either sharp or rounded-tip mode depending on tipRadius.

If showTips is true, dots are drawn at each outer vertex. If showInner is true, dots are drawn at each inner valley. If showCenter is true, the center SWPoint is drawn.

draw()
Method

Pixel-space draw — treats center.x/y and radii as raw pixel values. Use when not working with an SWGrid.

Sharp vs. Rounded-Tip Detail

ModetipRadiusp5 API usedVisual result
Sharp 0 beginShape / vertex / endShape(CLOSE) All 2n vertices are hard corners
Rounded >0 beginShape / vertex + quadraticVertex / endShape(CLOSE) Outer tips are chamfered; inner valleys stay sharp

The chamfer cuts each outer-tip vertex by tipRadius × grid.xScale pixels along both adjacent edges, then draws a quadratic arc through the original vertex as the control point. The radius is automatically clamped to 45% of the shorter adjacent edge so tips never degenerate.

Animation Methods

breathe(sinusoidOuter, sinusoidInner, t)
Method

Sets _scaleOuter from sinusoidOuter.getValue(t) and _scaleInner from sinusoidInner.getValue(t) (each clamped to ≥ 0.1). Pass null for either to hold that radius fixed at scale 1.

sinusoidOutersinusoidInnerEffect
SWSinusoid A SWSinusoid A Uniform pulse — tips and valleys move together
SWSinusoid A SWSinusoid B Tips and valleys breathe independently (different rates/ranges)
SWSinusoid A null Only tips pulse; valleys stay fixed
null SWSinusoid B Only valleys pulse; tips stay fixed

Note: In the demo sketch, each sinusoid uses its own elapsed time (t - outerBreathingStartTime vs. t - innerBreathingStartTime) so each starts cleanly from phase 0 when toggled on. The sketch sets _scaleOuter and _scaleInner directly rather than calling breathe(), to allow the per-sinusoid timing.

rotateAboutCenter(degPerSec, t)
Method

Sets _animRotDeg = degPerSec × t. Use with a frame-accumulating timer (totalRotationTime) so the star spins continuously. CCW positive; negative values spin CW.

star.rotateAboutCenter(rotationRate, totalRotationTime);
totalRotationTime += 1 / frameRate();
transform({ sinusoidOuter, sinusoidInner, t, degPerSec })
Method

Convenience method that sets _scaleOuter, _scaleInner, and _animRotDeg in one call. Equivalent to calling breathe() and rotateAboutCenter() separately.

reset()
Method

Restores outerRadius and innerRadius to their original values, resets _scaleOuter = _scaleInner = 1, _animRotDeg = 0, and restores the center to originalCenter. Does not reset the static rotation property.

Utility Methods

getOuterVerticesUserCoords()
Method

Returns an array of { x, y, isOuter: true } objects for the n outer tips in user coordinates, reflecting the current animation state.

getInnerVerticesUserCoords()
Method

Returns an array of { x, y, isOuter: false } objects for the n inner valleys in user coordinates. The outer and inner arrays interleave to form the full 2n vertex ring.

centerContainsPoint(px, py, grid, tolerance)
Method

Returns true if screen point (px, py) is within tolerance pixels (default 12) of the star's center. Used to implement drag-to-reposition in the sketch.

toString()
Method

Returns a human-readable summary string.

console.log(star.toString());
// → SWStar(center: (0.00, 0.00), outerR: 6.00,
//     innerR: 2.50, points: 5, area: 59.44, rotation: 0.0°)

Examples

Basic Setup — 5-Pointed Star

let grid, star;

function setup() {
    createCanvas(600, 500);
    grid = new SWGrid({ UL: new SWPoint(-12, 10), LR: new SWPoint(12, -10) });
    grid.init(width, height);

    const fill   = SWColor.fromHex("#fbbf24");
    fill.setAlphaTo(70);
    const stroke = fill.createDarkerColor(0.75);
    stroke.setAlphaTo(100);

    star = new SWStar(
        new SWPoint(0, 0),   // center
        6,                   // outer radius
        2.5,                 // inner radius
        5,                   // 5 points
        fill,
        { strokeColor: stroke, strokeWeight: 3 }
    );
}

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

Independent Breathing

let outerSin, innerSin;
let outerOn = false, innerOn = false;
let outerStart, innerStart;

function setup() {
    // ... grid and star as above ...
    // Tips breathe fast (2s period)
    outerSin = new SWSinusoid(0.4, (2*Math.PI)/2.0, 1.1, -Math.PI/6);
    // Valleys breathe slow (3s period)
    innerSin = new SWSinusoid(0.4, (2*Math.PI)/3.0, 1.0, Math.PI/4);
}

function draw() {
    const t = millis() / 1000;
    star._scaleOuter = outerOn ? Math.max(0.1, outerSin.getValue(t - outerStart)) : 1;
    star._scaleInner = innerOn ? Math.max(0.1, innerSin.getValue(t - innerStart)) : 1;
    star.drawOnGrid(grid);
}

function keyPressed() {
    if (key === 'b') { outerOn = !outerOn; if (outerOn) outerStart = millis()/1000; }
    if (key === 'n') { innerOn = !innerOn; if (innerOn) innerStart = millis()/1000; }
}

Uniform Pulsing (Same Sinusoid)

// Passing the SAME sinusoid to breathe() makes tips and valleys
// scale together — producing a uniform pulse like SWRegularPolygon:
const bothSin = new SWSinusoid(0.3, (2*Math.PI)/2.0, 1.0, 0);
star.breathe(bothSin, bothSin, t);

Spinning

let totalRotT = 0;
const FR = 30;

function draw() {
    background(240);
    grid.draw();
    star.rotateAboutCenter(20, totalRotT); // 20°/s
    totalRotT += 1 / FR;
    star.drawOnGrid(grid);
}

Rounded Tips

// Set at construction or at runtime:
star.tipRadius = 0.8;   // chamfer outer tips by 0.8 user-units
// The pixel chamfer = tipRadius * grid.xScale, so it scales
// naturally when the canvas is resized.

Vertex Inspection

// Get outer-tip positions (user coords):
const outerVerts = star.getOuterVerticesUserCoords();
for (const v of outerVerts) {
    console.log(`tip: (${v.x.toFixed(2)}, ${v.y.toFixed(2)})`);
}

// Get inner-valley positions:
const innerVerts = star.getInnerVerticesUserCoords();
for (const v of innerVerts) {
    console.log(`valley: (${v.x.toFixed(2)}, ${v.y.toFixed(2)})`);
}

Tips & Best Practices

📌 Outer vs. Inner Radius
outerRadius is the circumradius — center to tip. innerRadius is the inradius — center to valley. A classic 5-pointed star uses a ratio of about 2.6:1 (outer:inner). Approaching 1:1 produces a circle; exceeding 1:1 (inner > outer) produces a complex star with crossing edges.
📌 Independent Breathing Timing
The demo sketch drives each sinusoid with its own elapsed time (t - outerBreathingStartTime) so each animation starts cleanly from phase 0 when toggled on. This is more flexible than calling breathe() directly, which uses a single shared time. Copy the sketch pattern for the most controllable result.
📌 Tip Rounding Is on Outer Vertices Only
tipRadius > 0 chamfers the n outer vertices. The n inner valleys are always drawn with plain vertex() — they remain sharp regardless of the tip radius. This asymmetric rounding is intentional: it softens the pointy tips while preserving the crisp inward V-shape of the valleys.
📌 First Point at Top
The first outer tip is always placed at −90° (12 o'clock) before rotation is applied. A 4-pointed star therefore makes a diamond by default; set rotation = 45 for a plus/cross orientation.
📌 Rebuilding After Point-Count Change
You can change star.numPoints directly, but center-point style (strokeColor, strokeWeight) will not be refreshed. For a clean rebuild, call buildStar() with the new point count — the sketch pattern shown in the demo is the recommended approach.
📌 Area Formula
SWStar uses the shoelace formula on the 2n computed vertices to get area — this is exact for any outer/inner ratio and naturally handles all the star geometry without needing a closed-form formula. The result animates live with the breathing scales.

Source Code

Complete source for swStar.js:

/*
File:    swStar.js
Date:    2026-02-28
Author:  klp + GitHub Copilot
App:     SketchWaveTNT2026-02-28-Stg6
Purpose: SWStar — a regular n-pointed star with independently-breathing
         outer and inner radii, optional tip rounding, and rotation animation.

  Breathing design (mirrors SWRectangle's independent X/Y breathing):
    outerRadius × _scaleOuter — controls how far tips reach
    innerRadius × _scaleInner — controls how deep the valleys are

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

console.log("[swStar.js] SWStar class loaded.");

class SWStar {

    constructor(center, outerRadius, innerRadius, numPoints, fillColor, options = {}) {
        this.center      = center;
        this.outerRadius = outerRadius;
        this.innerRadius = innerRadius;
        this.numPoints   = Math.max(2, Math.round(numPoints));
        this.fillColor   = fillColor;

        this.tipRadius    = options.tipRadius    !== undefined ? options.tipRadius    : 0;
        this.strokeColor  = options.strokeColor  || undefined;
        this.strokeWeight = options.strokeWeight !== undefined ? options.strokeWeight : 2;
        this.showCenter   = options.showCenter   !== undefined ? options.showCenter   : false;
        this.showTips     = options.showTips     !== undefined ? options.showTips     : false;
        this.showInner    = options.showInner    !== undefined ? options.showInner    : false;
        this.rotation     = options.rotation     || 0;

        const cwt = this.strokeWeight > 0 ? this.strokeWeight : 4;
        const cc  = (this.strokeColor && this.strokeColor.col)
                        ? this.strokeColor
                        : (typeof swBlack !== 'undefined' ? swBlack : new SWColor(0, 0, 0, 100, "black"));
        this.center.strokeWeight = cwt;
        this.center.strokeColor  = cc;

        this._scaleOuter = 1;
        this._scaleInner = 1;
        this._animRotDeg = 0;
        this.originalOuterRadius = outerRadius;
        this.originalInnerRadius = innerRadius;
        this.originalCenter = new SWPoint(center.x, center.y, undefined, cwt, cc);
    }

    get currentOuterRadius() { return this.outerRadius * this._scaleOuter; }
    get currentInnerRadius() { return this.innerRadius * this._scaleInner; }
    get tipLength()          { return this.currentOuterRadius - this.currentInnerRadius; }

    get area() {
        const verts = this._computeUserVertices();
        const n2    = verts.length;
        let   sum   = 0;
        for (let i = 0; i < n2; i++) {
            const j = (i + 1) % n2;
            sum += verts[i].x * verts[j].y - verts[j].x * verts[i].y;
        }
        return Math.abs(sum) / 2;
    }

    get perimeter() {
        const R   = this.currentOuterRadius;
        const r   = this.currentInnerRadius;
        const arm = Math.sqrt(R*R + r*r - 2*R*r*Math.cos(Math.PI / this.numPoints));
        return 2 * this.numPoints * arm;
    }

    _computeUserVertices() {
        const Ro       = this.currentOuterRadius;
        const Ri       = this.currentInnerRadius;
        const totalRad = (this.rotation + this._animRotDeg) * Math.PI / 180;
        const base     = -Math.PI / 2;
        const n        = this.numPoints;
        const pts      = [];
        for (let i = 0; i < n; i++) {
            const outerAngle = base + totalRad + (2 * Math.PI * i / n);
            const innerAngle = outerAngle + (Math.PI / n);
            pts.push({ x: this.center.x + Ro * Math.cos(outerAngle),
                       y: this.center.y + Ro * Math.sin(outerAngle), isOuter: true });
            pts.push({ x: this.center.x + Ri * Math.cos(innerAngle),
                       y: this.center.y + Ri * Math.sin(innerAngle), isOuter: false });
        }
        return pts;
    }

    _applyFillStroke() {
        if (this.fillColor   && this.fillColor.col)   { fill(this.fillColor.col);   } else { noFill();   }
        if (this.strokeColor && this.strokeColor.col) {
            stroke(this.strokeColor.col); strokeWeight(this.strokeWeight);
        } else { noStroke(); }
    }

    _drawStar(pts) {
        beginShape();
        for (const p of pts) vertex(p.x, p.y);
        endShape(CLOSE);
    }

    _drawRoundedTips(pts, pixelRadius) {
        const n2 = pts.length;
        beginShape();
        for (let i = 0; i < n2; i++) {
            const curr = pts[i];
            if (!curr.isOuter) {
                vertex(curr.x, curr.y);
            } else {
                const prev = pts[(i - 1 + n2) % n2];
                const next = pts[(i + 1) % n2];
                const dx1 = prev.x - curr.x, dy1 = prev.y - curr.y;
                const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);
                const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
                const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);
                const r1 = Math.min(pixelRadius, len1 * 0.45);
                const r2 = Math.min(pixelRadius, len2 * 0.45);
                const p1 = { x: curr.x + (dx1/len1)*r1, y: curr.y + (dy1/len1)*r1 };
                const p2 = { x: curr.x + (dx2/len2)*r2, y: curr.y + (dy2/len2)*r2 };
                vertex(p1.x, p1.y);
                quadraticVertex(curr.x, curr.y, p2.x, p2.y);
            }
        }
        endShape(CLOSE);
    }

    draw() {
        const pts = this._computeUserVertices();
        this._applyFillStroke();
        if (this.tipRadius > 0) this._drawRoundedTips(pts, this.tipRadius);
        else                     this._drawStar(pts);
        noStroke(); noFill(); strokeWeight(1);
        if (this.showCenter && this.center && this.center.draw) this.center.draw();
    }

    drawOnGrid(grid) {
        const userVerts = this._computeUserVertices();
        const pts = userVerts.map(v => ({
            x: grid.userToScreen(v.x, v.y).x,
            y: grid.userToScreen(v.x, v.y).y,
            isOuter: v.isOuter
        }));
        this._applyFillStroke();
        if (this.tipRadius > 0) this._drawRoundedTips(pts, this.tipRadius * grid.xScale);
        else                     this._drawStar(pts);
        noStroke(); noFill(); strokeWeight(1);
        if (this.showTips || this.showInner) {
            const outerFill = (this.strokeColor && this.strokeColor.col)
                                  ? this.strokeColor.col : color(40, 100, 100);
            const innerFill = color(50, 80, 100);
            noStroke();
            for (const p of pts) {
                if (p.isOuter && this.showTips)  { fill(outerFill); ellipse(p.x, p.y, 9, 9); }
                else if (!p.isOuter && this.showInner) { fill(innerFill); ellipse(p.x, p.y, 7, 7); }
            }
            noFill();
        }
        if (this.showCenter && this.center && this.center.drawOnGrid) this.center.drawOnGrid(grid);
    }

    breathe(sinusoidOuter, sinusoidInner, t) {
        this._scaleOuter = sinusoidOuter ? Math.max(0.1, sinusoidOuter.getValue(t)) : 1;
        this._scaleInner = sinusoidInner ? Math.max(0.1, sinusoidInner.getValue(t)) : 1;
    }

    rotateAboutCenter(degPerSec, t) { this._animRotDeg = degPerSec * t; }

    transform({ sinusoidOuter = null, sinusoidInner = null, t = 0, degPerSec = null } = {}) {
        this._scaleOuter = sinusoidOuter ? Math.max(0.1, sinusoidOuter.getValue(t)) : 1;
        this._scaleInner = sinusoidInner ? Math.max(0.1, sinusoidInner.getValue(t)) : 1;
        this._animRotDeg = degPerSec !== null ? degPerSec * t : 0;
    }

    reset() {
        this.outerRadius = this.originalOuterRadius;
        this.innerRadius = this.originalInnerRadius;
        this._scaleOuter = 1;
        this._scaleInner = 1;
        this._animRotDeg = 0;
        this.center.x    = this.originalCenter.x;
        this.center.y    = this.originalCenter.y;
    }

    getOuterVerticesUserCoords() { return this._computeUserVertices().filter(v => v.isOuter); }
    getInnerVerticesUserCoords() { return this._computeUserVertices().filter(v => !v.isOuter); }

    centerContainsPoint(px, py, grid, tolerance = 12) {
        const { x: cx, y: cy } = grid.userToScreen(this.center.x, this.center.y);
        const dx = px - cx, dy = py - cy;
        return (dx*dx + dy*dy) <= tolerance * tolerance;
    }

    toString() {
        const totalDeg = this.rotation + this._animRotDeg;
        return `SWStar(center: (${this.center.x.toFixed(2)}, ${this.center.y.toFixed(2)}), ` +
               `outerR: ${this.currentOuterRadius.toFixed(2)}, ` +
               `innerR: ${this.currentInnerRadius.toFixed(2)}, ` +
               `points: ${this.numPoints}, area: ${this.area.toFixed(2)}, ` +
               `rotation: ${totalDeg.toFixed(1)}\u00b0)`;
    }

}//end class SWStar