⬭ SWEllipse Class Reference

Ellipse with breathing, rotation, eccentricity & foci — SketchWaveJS Stage

Overview

SWEllipse is a standalone SketchWaveJS shape class that draws an ellipse defined by a center SWPoint and two independent radii: radiusX (horizontal semi-axis) and radiusY (vertical semi-axis). Because an ellipse has no corner vertices, rotation is implemented via p5.js's push()/translate()/rotate()/pop() pattern — the ellipse displays correctly at any angle without recalculating any coordinates.

🌀 Ellipse Geometry

An ellipse is the set of all points where the sum of the distances to two fixed points (the foci) is constant. When the two foci coincide, the ellipse becomes a circle.

📐 Coordinate Convention

All positions are in user (grid) coordinates. The drawOnGrid(grid) method converts to screen pixels automatically. Rotation is in degrees CCW (counter-clockwise), matching standard math convention.

Quick Start

// In setup():
const center = new SWPoint(0, 0);
const myEllipse = new SWEllipse(center, 9, 6, swLightGreen);

// In draw():
myEllipse.drawOnGrid(grid);

Constructor

new SWEllipse(center, radiusX, radiusY, fillColor, options = {})
ParameterTypeDefaultDescription
center SWPoint required Center point of the ellipse (user coordinates)
radiusX number required Horizontal semi-axis length (user units)
radiusY number required Vertical semi-axis length (user units)
fillColor SWColor required Fill color for the ellipse interior
options.strokeColor SWColor auto (darker fill) Border / outline color
options.strokeWeight number 2 Border thickness in pixels
options.showCenter boolean false Show center SWPoint when drawing
options.rotation number 0 Initial rotation in degrees CCW

Example

const fillColor   = new SWColor(152, 68, 83, 70, "teal");
const borderColor = fillColor.createDarkerColor(0.75);
const center      = new SWPoint(2, -1);

const e = new SWEllipse(center, 8, 5, fillColor, {
    strokeColor:  borderColor,
    strokeWeight: 3,
    showCenter:   true,
    rotation:     30     // 30° counter-clockwise
});

Basic Properties

These are direct read/write properties set by the constructor and modifiable at any time.

center  SWPoint

The center point of the ellipse. Moving center.x/center.y repositions the ellipse. Used for drag, trail, and hit-testing.

radiusX  radiusY  number

Current horizontal and vertical semi-axis lengths (mutable). During breathing, these are set to originalRadiusX × scaleX etc. Set directly to resize without animation.

originalRadiusX  originalRadiusY  number

The baseline radii stored at construction time (or when rebuilt from the sketch). breathe() multiplies these by a sinusoid scale factor.

rotation  number

Current rotation of the ellipse in degrees CCW. Set this every frame in the sketch to animate rotation: ellipseABC.rotation = staticRotation + totalRotationDeg;

fillColor  strokeColor  SWColor

Fill and border colors. Reassign to apply new colors; the next drawOnGrid() call uses the updated values.

strokeWeight  number

Border thickness in pixels. Set to 0 to draw with no border.

showCenter  boolean

If true, the center SWPoint is rendered inside drawOnGrid(). Use setShowCenter(bool) for a fluent setter.

Geometric Properties

All geometric getters are read-only and recalculate from the current radiusX/radiusY values each time they are accessed.

area  getter

Returns π × radiusX × radiusY. This is the exact formula for ellipse area.

console.log(e.area.toFixed(2)); // e.g. "169.65"
perimeter  getter

Returns an approximation using Ramanujan's second formula:

P ≈ π × [3(a+b) − √((3a+b)(a+3b))]

where a = semiMajorAxis, b = semiMinorAxis. This is accurate to within 0.02% for most practical ellipses; exact for circles.

semiMajorAxis  semiMinorAxis  getter

semiMajorAxis = Math.max(radiusX, radiusY) — the longer radius.
semiMinorAxis = Math.min(radiusX, radiusY) — the shorter radius.

eccentricity  getter

Measures how "stretched" the ellipse is:

e = √(1 − (b/a)²)

  • e = 0 — perfect circle
  • 0 < e < 1 — standard ellipse
  • approaches 1 as the ellipse becomes very long and thin
console.log(e.eccentricity.toFixed(4)); // e.g. "0.7416"
focalDistance  getter

The distance c from the center to each focus along the major axis:

c = √(a² − b²)

For a circle, focalDistance === 0. Used internally by getFociUserCoords().

aspectRatio  getter

Returns radiusX / radiusY. Greater than 1 = wider than tall; less than 1 = taller than wide; equal to 1 = circle.

Classification

isCircle  getter

Returns true when |radiusX − radiusY| < 1% × max(radiusX, radiusY). Useful for conditionally showing/hiding the foci, since a circle's foci coincide at its center.

if (myEllipse.isCircle) {
    console.log("This is a circle; eccentricity ≈ 0");
}

Drawing Methods

drawOnGrid(grid)  method

Primary draw method. Converts the center SWPoint from user (grid) coordinates to screen pixels, scales radiusX/radiusY by the grid's pixel-per-unit values, then uses p5's push()/translate()/rotate()/ellipse()/pop() to render the shape.

💡 Rotation note: p5.js uses a clockwise positive angle convention internally, but SWEllipse uses CCW positive to match standard math. The class converts automatically: rotate(-rotation × π/180).
// In draw():
ellipseABC.drawOnGrid(grid);
draw()  method

Draws the ellipse in screen (pixel) coordinates directly. Use this when you are not using a SWGrid and instead positioning the ellipse in raw canvas pixels. center.x/center.y are treated as pixel values.

Animation Methods

breathe(sinusoidX, sinusoidY, t)  method
breathe(sinusoidX: SWSinusoid | null, sinusoidY: SWSinusoid | null, t: number)

Scales radiusX and/or radiusY using sinusoidal functions evaluated at time t (seconds). Pass null for an axis to leave it unaffected.

ModeCall Pattern
Uniform breathe(sin, sin, t)
Horizontalbreathe(sinX, null, t)
Vertical breathe(null, sinY, t)
Independentbreathe(sinX, sinY, t)
// In draw(), example:
const t = (millis() - breathingStartTime) / 1000;
ellipseABC.breathe(breatheSinusoidX, null, t); // horizontal only
reset()  method

Restores radiusX/radiusY to their original values and resets the center position to originalCenter. Does not reset rotation (manage totalRotationDeg in your sketch).

Foci Methods

getFociUserCoords()  method
getFociUserCoords() → { f1: {x, y}, f2: {x, y} }

Returns the positions of both focal points in user (grid) coordinates, accounting for the current rotation. The foci lie along the major axis; when the ellipse is rotated, the focal positions rotate correspondingly.

const { f1, f2 } = ellipseABC.getFociUserCoords();
console.log(`F1 = (${f1.x.toFixed(2)}, ${f1.y.toFixed(2)})`);
console.log(`F2 = (${f2.x.toFixed(2)}, ${f2.y.toFixed(2)})`);
drawFociOnGrid(grid, dotSize, dotColor)  method
drawFociOnGrid(grid: SWGrid, dotSize?: number = 8, dotColor?: SWColor)

Draws the two focal points as small filled dots on the canvas. Returns immediately (no drawing) if isCircle is true. The dot color defaults to orange if no dotColor is supplied.

// In draw(), after drawOnGrid():
if (showFoci) {
    const fociColor = new SWColor(24, 90, 100, 100, "orange");
    ellipseABC.drawFociOnGrid(grid, 8, fociColor);
}

Vertices Methods

The four vertex extrema are the points at the ends of the major (X) and minor (Y) axes — i.e. the rightmost, leftmost, topmost, and bottommost points of the ellipse in its own coordinate frame. When the ellipse is rotated, the vertices rotate with it.

getVerticesUserCoords()  method
getVerticesUserCoords() → { vRight, vLeft, vTop, vBottom }

Returns the positions of all four vertex extrema in user (grid) coordinates, accounting for the current rotation. Each vertex is an {x, y} plain object.

KeyAxisUnrotated offset
vRight +X (major end)(+radiusX, 0)
vLeft −X (major end)(−radiusX, 0)
vTop +Y (minor end)(0, +radiusY)
vBottom−Y (minor end)(0, −radiusY)
const { vRight, vLeft, vTop, vBottom } = ellipseABC.getVerticesUserCoords();
console.log(`Right vertex: (${vRight.x.toFixed(2)}, ${vRight.y.toFixed(2)})`);
drawVerticesOnGrid(grid, dotSize, dotColor)  method
drawVerticesOnGrid(grid: SWGrid, dotSize?: number = 8, dotColor?: SWColor)

Draws the four vertex extrema as small filled dots on the canvas. The dot color defaults to blue (HSB 210, 75, 90) if no dotColor is supplied, visually distinguishing them from the orange foci dots.

// In draw(), after drawOnGrid():
if (showVertices) {
    const verticesColor = new SWColor(210, 75, 90, 100, "vertexBlue");
    ellipseABC.drawVerticesOnGrid(grid, 8, verticesColor);
}
💡 Vertices vs. Foci: Foci are interior points along the major axis; vertices are boundary points on the ellipse's perimeter at each axis end. Both sets rotate with the ellipse.

Utility Methods

setShowCenter(bool)  method

Shows or hides the center SWPoint when drawOnGrid() is called.

ellipseABC.setShowCenter(true);  // show
ellipseABC.setShowCenter(false); // hide
centerContainsPoint(px, py, grid, tolerance)  method
centerContainsPoint(px: number, py: number, grid: SWGrid, tolerance?: number = 12) → boolean

Returns true if the screen point (px, py) (e.g. p5's mouseX, mouseY) is within tolerance pixels of the ellipse's center. Used for mouse hit-testing to enable center-drag.

// In mousePressed():
if (ellipseABC.centerContainsPoint(mouseX, mouseY, grid, 15)) {
    isDraggingCenter = true;
}
toString()  method

Returns a descriptive string including center, radii, area, and rotation.

console.log(ellipseABC.toString());
// SWEllipse(center: (0.00, 0.00), radiusX: 9.00, radiusY: 6.00, area: 169.65, rotation: 45.0°)

Examples

Basic Ellipse in a Sketch

let myEllipse, grid;

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

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

    const fill   = new SWColor(152, 68, 83, 70, "teal");
    const border = fill.createDarkerColor(0.75);
    const center = new SWPoint(0, 0, undefined, 6, border);

    myEllipse = new SWEllipse(center, 8, 5, fill, {
        strokeColor:  border,
        strokeWeight: 2
    });
}

function draw() {
    background(0, 0, 93);
    grid.draw();
    myEllipse.drawOnGrid(grid);
}

Breathing + Rotation + Foci

let sinX, sinY;
let totalRotationDeg = 0;
let lastUpdate = 0;
let fociColor;

function setup() {
    // ... (grid and ellipse setup as above)
    const amp  = (1.6 - 0.8) / 2;
    const freq = (2 * Math.PI) / 2.0;  // 2-second period
    const mid  = (0.8 + 1.6) / 2;
    sinX = new SWSinusoid(amp, freq, mid, -Math.PI / 6);
    sinY = new SWSinusoid(amp, freq, mid, +Math.PI / 6); // phase-shifted

    fociColor = new SWColor(24, 90, 100, 100, "orange");
    lastUpdate = millis();
}

function draw() {
    background(0, 0, 93);
    grid.draw();

    // Breathing
    const t = millis() / 1000;
    myEllipse.breathe(sinX, sinY, t);   // independent mode

    // Rotation (40°/s)
    const now   = millis();
    totalRotationDeg += 40 * (now - lastUpdate) / 1000;
    lastUpdate = now;
    myEllipse.rotation = totalRotationDeg;

    myEllipse.drawOnGrid(grid);

    // Foci
    myEllipse.drawFociOnGrid(grid, 8, fociColor);
}

Making a Circle

// Equal radii → isCircle === true
const circle = new SWEllipse(center, 7, 7, swLightBlue);
console.log(circle.isCircle);       // true
console.log(circle.eccentricity);   // 0
console.log(circle.focalDistance);  // 0

Tips & Best Practices

💡 Rotation is in user degrees (CCW), not p5 radians.
SWEllipse accepts rotation in degrees and converts to p5 radians internally. Keep rotation in degrees in your sketch code for readability.
💡 Resize with buildEllipse(), not direct property assignment.
When using the demo sketch pattern, call buildEllipse() to fully reconstruct the instance when sliders change. Direct assignment to radiusX/radiusY works for animation but skips the originalRadiusX/Y update.
💡 Foci only exist when isCircle is false.
Always check !ellipseABC.isCircle before calling drawFociOnGrid() if you have not already (the method handles it internally, but checking avoids unnecessary calls). Vertices, by contrast, are always well-defined — even a circle has four axis-end points.
💡 Breathing does not interact with rotation.
breathe() only changes radiusX/radiusY; rotation is a separate scalar. You can breathe and spin simultaneously without special handling.
💡 Trails are available on the center, foci, and vertices.
SWEllipse has no corner vertices, but three sets of special points can leave trails: the center (draggable), the two foci (toggled with F), and the four vertex extrema (toggled with V). All three track the live positions of their respective points as the ellipse breathes and rotates.
💡 p5 ellipse() takes diameters, not radii.
Internally, drawOnGrid() calls ellipse(0, 0, 2*rx, 2*ry) — the 3rd and 4th arguments are widths, i.e. twice the radii. This is handled automatically; just pass radii to the SWEllipse constructor.

Source Code

Complete source for swEllipse.js:

/*
File: swEllipse.js
Date: 2026-02-28
Author: klp + GitHub Copilot
Workspace: SketchWaveTNT2026-02-28-Stg6
Purpose: SWEllipse class for SketchWaveJS
Comment(s):

SWEllipse: An ellipse with a center SWPoint, radiusX (semi-width), radiusY (semi-height),
fill color, optional border, rotation, and animation features.

Unlike SWRectangle, SWEllipse has a continuous boundary — no corner vertices.
Rotation is achieved via p5.js push/translate/rotate/pop, so it is purely visual
and does not recompute point arrays, making it efficient for complex animations.

Breathing scales radiusX and/or radiusY independently from originalRadiusX/Y.
Rotation tracks a cumulative angle (degrees, CCW in user/math convention).

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

console.log("[swEllipse.js] SWEllipse class loaded.");

class SWEllipse {
    /**
     * @param {SWPoint} center     - Center point (SWPoint instance)
     * @param {number}  radiusX   - Semi-width  (horizontal radius, user units)
     * @param {number}  radiusY   - Semi-height (vertical radius,  user units)
     * @param {SWColor} fillColor - Fill color (SWColor instance)
     * @param {Object}  [options]
     *   strokeColor  : SWColor  — border color (optional)
     *   strokeWeight : number   — border thickness (default 2)
     *   showCenter   : boolean  — show center SWPoint (default false)
     *   rotation     : number   — initial rotation in degrees CCW (default 0)
     */
    constructor(center, radiusX, radiusY, fillColor, options = {}) {
        this.center      = center;
        this.radiusX     = radiusX;
        this.radiusY     = radiusY;
        this.fillColor   = fillColor;
        this.strokeColor = options.strokeColor   || undefined;
        this.strokeWeight = options.strokeWeight !== undefined ? options.strokeWeight : 2;
        this.showCenter  = options.showCenter    !== undefined ? options.showCenter   : false;
        this.rotation    = options.rotation      || 0;  // degrees CCW

        // Set center-point styling
        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;

        // Store originals for animation & drag-reset
        this.originalRadiusX = radiusX;
        this.originalRadiusY = radiusY;
        this.originalCenter  = new SWPoint(
            center.x, center.y, undefined, cwt, cc
        );
    }//end constructor

    // ── Geometric getters ──────────────────────────────────────────────

    /** Area = π × radiusX × radiusY */
    get area() {
        return Math.PI * this.radiusX * this.radiusY;
    }//end area

    /**
     * Perimeter approximation (Ramanujan's second formula).
     * Exact only for circles; very accurate for most ellipses.
     */
    get perimeter() {
        const a = Math.max(this.radiusX, this.radiusY);
        const b = Math.min(this.radiusX, this.radiusY);
        return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
    }//end perimeter

    /** radiusX / radiusY */
    get aspectRatio() {
        return this.radiusX / this.radiusY;
    }//end aspectRatio

    /** Larger of radiusX, radiusY */
    get semiMajorAxis() {
        return Math.max(this.radiusX, this.radiusY);
    }//end semiMajorAxis

    /** Smaller of radiusX, radiusY */
    get semiMinorAxis() {
        return Math.min(this.radiusX, this.radiusY);
    }//end semiMinorAxis

    /**
     * Eccentricity e = √(1 − (b/a)²).
     * e = 0 for a circle; approaches 1 as the ellipse becomes very elongated.
     */
    get eccentricity() {
        const a = this.semiMajorAxis, b = this.semiMinorAxis;
        if (a < 0.0001) return 0;
        return Math.sqrt(1 - (b * b) / (a * a));
    }//end eccentricity

    /** Distance from center to each focus along the major axis: c = √(a² − b²) */
    get focalDistance() {
        const a = this.semiMajorAxis, b = this.semiMinorAxis;
        return Math.sqrt(Math.max(0, a * a - b * b));
    }//end focalDistance

    /**
     * True when radiusX ≈ radiusY (within 1 % relative tolerance).
     */
    get isCircle() {
        const mx = Math.max(this.radiusX, this.radiusY);
        return mx < 0.0001 || Math.abs(this.radiusX - this.radiusY) < 0.01 * mx;
    }//end isCircle

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

    /** Show or hide the center SWPoint */
    setShowCenter(show = true) {
        this.showCenter = show;
    }//end setShowCenter

    // ── Rendering ─────────────────────────────────────────────────────

    /**
     * Draws the ellipse in screen (pixel) coordinates.
     * radiusX and radiusY are treated as pixel values.
     */
    draw() {
        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(); }

        push();
        translate(this.center.x, this.center.y);
        rotate(-this.rotation * Math.PI / 180); // p5 CW = user CCW
        ellipse(0, 0, 2 * this.radiusX, 2 * this.radiusY);
        pop();

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

        if (this.showCenter && this.center && this.center.draw) {
            this.center.draw();
        }
    }//end draw

    /**
     * Draws the ellipse in user / grid coordinates via the given SWGrid.
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        const { x: cx, y: cy } = grid.userToScreen(this.center.x, this.center.y);
        const rx = grid.xScale * this.radiusX;
        const ry = grid.yScale * this.radiusY;

        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(); }

        push();
        translate(cx, cy);
        rotate(-this.rotation * Math.PI / 180); // convert CCW user-angle → CW screen-angle
        ellipse(0, 0, 2 * rx, 2 * ry);
        pop();

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

        if (this.showCenter && this.center && this.center.drawOnGrid) {
            this.center.drawOnGrid(grid);
        }
    }//end drawOnGrid

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

    /**
     * Scales radiusX and/or radiusY using SWSinusoid instances.
     * Pass null for an axis to leave it unscaled.
     *
     * Modes:
     *   Uniform:     breathe(sin, sin, t)          — both axes share one sinusoid
     *   Horizontal:  breathe(sinX, null, t)         — only X grows/shrinks
     *   Vertical:    breathe(null, sinY, t)         — only Y grows/shrinks
     *   Independent: breathe(sinX, sinY, t)         — separate sinusoids per axis
     *
     * @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) {
        const scaleX = sinusoidX ? sinusoidX.getValue(t) : 1.0;
        const scaleY = sinusoidY ? sinusoidY.getValue(t) : 1.0;
        this.radiusX = this.originalRadiusX * scaleX;
        this.radiusY = this.originalRadiusY * scaleY;
    }//end breathe

    /**
     * Resets radiusX/Y to their original values and restores the center position.
     * Does NOT reset rotation (handled externally by the sketch).
     */
    reset() {
        this.radiusX = this.originalRadiusX;
        this.radiusY = this.originalRadiusY;
        this.center.x = this.originalCenter.x;
        this.center.y = this.originalCenter.y;
    }//end reset

    // ── Foci helpers ──────────────────────────────────────────────────

    /**
     * Returns the two focal points in user coordinates, accounting for rotation.
     * If isCircle, both points coincide at the center.
     * @returns {{f1: {x, y}, f2: {x, y}}}
     */
    getFociUserCoords() {
        const fd     = this.focalDistance;
        const rotRad = this.rotation * Math.PI / 180;
        const cx     = this.center.x, cy = this.center.y;

        let dx, dy;
        if (this.radiusX >= this.radiusY) {
            // Major axis is horizontal before rotation
            dx = fd * Math.cos(rotRad);
            dy = fd * Math.sin(rotRad);
        } else {
            // Major axis is vertical before rotation; rotate 90° extra
            dx = -fd * Math.sin(rotRad);
            dy =  fd * Math.cos(rotRad);
        }

        return {
            f1: { x: cx + dx, y: cy + dy },
            f2: { x: cx - dx, y: cy - dy }
        };
    }//end getFociUserCoords

    /**
     * Draws the two foci as small filled dots on a grid.
     * @param {SWGrid} grid
     * @param {number} [dotSize=8] — screen-pixel diameter of each focus dot
     * @param {SWColor} [dotColor] — fill color for the dots
     */
    drawFociOnGrid(grid, dotSize = 8, dotColor = undefined) {
        if (this.isCircle) return; // no distinct foci for a circle
        const { f1, f2 } = this.getFociUserCoords();
        const s1 = grid.userToScreen(f1.x, f1.y);
        const s2 = grid.userToScreen(f2.x, f2.y);

        if (dotColor && dotColor.col) { fill(dotColor.col); } else { fill(255, 120, 0); }
        noStroke();
        ellipse(s1.x, s1.y, dotSize, dotSize);
        ellipse(s2.x, s2.y, dotSize, dotSize);
        noFill();
    }//end drawFociOnGrid

    // ── Vertex extrema helpers ────────────────────────────────────────────

    /**
     * Returns the four vertex extrema in user coordinates, accounting for rotation.
     * These are the points at the ends of the major (X) and minor (Y) axes.
     * @returns {{ vRight, vLeft, vTop, vBottom }}
     */
    getVerticesUserCoords() {
        const rotRad = this.rotation * Math.PI / 180;
        const cx = this.center.x, cy = this.center.y;
        const rX = this.radiusX,  rY = this.radiusY;
        const cosR = Math.cos(rotRad), sinR = Math.sin(rotRad);
        return {
            vRight:  { x: cx + rX * cosR, y: cy + rX * sinR },
            vLeft:   { x: cx - rX * cosR, y: cy - rX * sinR },
            vTop:    { x: cx - rY * sinR, y: cy + rY * cosR },
            vBottom: { x: cx + rY * sinR, y: cy - rY * cosR }
        };
    }//end getVerticesUserCoords

    /**
     * Draws the four vertex extrema as small filled dots on a grid.
     * @param {SWGrid} grid
     * @param {number}  [dotSize=8]  — screen-pixel diameter
     * @param {SWColor} [dotColor]   — fill color (default: blue)
     */
    drawVerticesOnGrid(grid, dotSize = 8, dotColor = undefined) {
        const { vRight, vLeft, vTop, vBottom } = this.getVerticesUserCoords();
        if (dotColor && dotColor.col) { fill(dotColor.col); } else { fill(210, 75, 90); }
        noStroke();
        [vRight, vLeft, vTop, vBottom].forEach(v => {
            const s = grid.userToScreen(v.x, v.y);
            ellipse(s.x, s.y, dotSize, dotSize);
        });
        noFill();
    }//end drawVerticesOnGrid

    // ── Utility ─────────────────────────────────────────────────────────────

    /**
     * Returns true if screen point (px, py) is inside or near the ellipse center
     * (within `tolerance` pixels). Used for mouse hit-testing the center handle.
     * @param {number} px
     * @param {number} py
     * @param {SWGrid} grid
     * @param {number} [tolerance=12]
     */
    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;
    }//end centerContainsPoint

    /**
     * String representation
     */
    toString() {
        return `SWEllipse(center: (${this.center.x.toFixed(2)}, ${this.center.y.toFixed(2)}), ` +
               `radiusX: ${this.radiusX.toFixed(2)}, radiusY: ${this.radiusY.toFixed(2)}, ` +
               `area: ${this.area.toFixed(2)}, rotation: ${this.rotation.toFixed(1)}\u00b0)`;
    }//end toString
}//end class SWEllipse

// export default SWEllipse;