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.
| Feature | Details |
|---|---|
| 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
| Parameter | Type | Description |
|---|---|---|
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
| Key | Type | Default | Description |
|---|---|---|---|
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
outerRadius × _scaleOuter. Change at runtime to resize tips; call reset() to revert.innerRadius × _scaleInner. When innerRadius approaches outerRadius, the star becomes a regular polygon.numPoints does not rebuild center style; for a clean rebuild call buildStar().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.drawOnGrid() call.Animation State (read only in practice)
breathe(), transform(), or directly in the sketch's draw loop. Minimum effective value 0.1._scaleOuter — the two can be at completely different values simultaneously.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.
| Property | Formula | Notes |
|---|---|---|
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
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.
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
| Mode | tipRadius | p5 API used | Visual 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
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.
| sinusoidOuter | sinusoidInner | Effect |
|---|---|---|
| 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.
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();
Convenience method that sets _scaleOuter, _scaleInner, and _animRotDeg in one call. Equivalent to calling breathe() and rotateAboutCenter() separately.
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
Returns an array of { x, y, isOuter: true } objects for the n outer tips in user coordinates, reflecting the current animation state.
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.
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.
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
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.
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.
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.
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.
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.
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