/*
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.

  A regular star is defined by a center SWPoint, an outer circumradius (center
  to tip), an inner radius (center to the valley between tips), and a number of
  points ≥ 2.  Vertices are computed on the fly — 2n vertices alternating
  outer-tip / inner-valley, with the first tip placed at the top (−90°).

  Breathing design (mirrors SWRectangle's independent X/Y breathing):
    outerRadius × _scaleOuter — controls how far tips reach
    innerRadius × _scaleInner — controls how deep the valleys are
    • Pass the SAME sinusoid for both → uniform pulsing (all in/out together)
    • Pass different sinusoids    → tips and valleys breathe independently
    • Pass null for either        → that radius stays fixed

  Tip rounding (tipRadius > 0):
    Uses quadratic bezier chamfering on the outer-tip vertices only. The valley
    vertices remain sharp.  tipRadius is in user units and scales with the grid.

  Rotation accumulates in _animRotDeg (degrees CCW) exactly like SWRegularPolygon.

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

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

class SWStar {

    /**
     * @param {SWPoint} center      — Center of the star (user coordinates)
     * @param {number}  outerRadius — Distance from center to tip (user units)
     * @param {number}  innerRadius — Distance from center to valley (user units)
     * @param {number}  numPoints   — Number of star points (integer ≥ 2)
     * @param {SWColor} fillColor   — Interior fill color
     * @param {Object}  [options]
     *   tipRadius    : number  — rounding on outer tips (0 = sharp, default 0)
     *   strokeColor  : SWColor — border color (optional)
     *   strokeWeight : number  — border thickness (default 2)
     *   showCenter   : boolean — draw center SWPoint (default false)
     *   showTips     : boolean — draw dots at outer-tip vertices (default false)
     *   showInner    : boolean — draw dots at inner-valley vertices (default false)
     *   rotation     : number  — initial static rotation in degrees CCW (default 0)
     */
    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;

        // Style the center SWPoint
        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;

        // ── Animation state ────────────────────────────────────────────────────
        this._scaleOuter = 1;   // breathing scale for outerRadius
        this._scaleInner = 1;   // breathing scale for innerRadius
        this._animRotDeg = 0;   // accumulated animation rotation (degrees CCW)

        // ── Originals for reset ────────────────────────────────────────────────
        this.originalOuterRadius = outerRadius;
        this.originalInnerRadius = innerRadius;
        this.originalCenter = new SWPoint(center.x, center.y, undefined, cwt, cc);
    }//end constructor

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

    /** Outer tip radius at the current breathing scale */
    get currentOuterRadius() { return this.outerRadius * this._scaleOuter; }

    /** Inner valley radius at the current breathing scale */
    get currentInnerRadius() { return this.innerRadius * this._scaleInner; }

    /** Distance from tip to valley along the star arm */
    get tipLength() { return this.currentOuterRadius - this.currentInnerRadius; }

    /**
     * Area using the shoelace formula on the 2n computed vertices.
     * This is exact for any regular star regardless of radii.
     */
    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;
    }//end area

    /**
     * Perimeter: 2n arm segments, each a line from inner valley to outer tip.
     * arm = √(R² + r² − 2Rr·cos(π/n))
     */
    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;
    }//end perimeter

    // ── Vertex computation ─────────────────────────────────────────────────────

    /**
     * Returns 2n user-coordinate vertices: outer tips at even indices (0,2,4…),
     * inner valleys at odd indices (1,3,5…). First outer tip points upward.
     * @returns {Array<{x: number, y: number, isOuter: boolean}>}
     */
    _computeUserVertices() {
        const Ro       = this.currentOuterRadius;
        const Ri       = this.currentInnerRadius;
        const totalRad = (this.rotation + this._animRotDeg) * Math.PI / 180;
        const base     = -Math.PI / 2;  // first tip at top
        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);  // midpoint between tips

            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;
    }//end _computeUserVertices

    // ── Drawing helpers ────────────────────────────────────────────────────────

    _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(); }
    }//end _applyFillStroke

    /** Sharp star path using plain vertices. */
    _drawStar(pts) {
        beginShape();
        for (const p of pts) vertex(p.x, p.y);
        endShape(CLOSE);
    }//end _drawStar

    /**
     * Star path with quadratic bezier chamfering on outer-tip vertices only.
     * Inner-valley vertices remain sharp. `pixelRadius` is in screen pixels.
     * @param {Array<{x,y,isOuter}>} pts
     * @param {number} pixelRadius
     */
    _drawRoundedTips(pts, pixelRadius) {
        const n2 = pts.length;
        beginShape();
        for (let i = 0; i < n2; i++) {
            const curr = pts[i];
            if (!curr.isOuter) {
                // Valley vertex — sharp
                vertex(curr.x, curr.y);
            } else {
                // Tip vertex — chamfer with quadratic bezier
                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);
    }//end _drawRoundedTips

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

    /** Draw in pixel screen coordinates (no grid). */
    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();
    }//end draw

    /**
     * Draw mapped through an SWGrid instance (user → screen coordinates).
     * @param {SWGrid} grid
     */
    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);
        }
    }//end drawOnGrid

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

    /**
     * Breathes the star by scaling outer and/or inner radii independently.
     * Mirrors SWRectangle's breathe(sinusoidX, sinusoidY, t) pattern.
     *
     * @param {SWSinusoid|null} sinusoidOuter — Scale driver for outer tips (null = fixed at 1)
     * @param {SWSinusoid|null} sinusoidInner — Scale driver for inner valleys (null = fixed at 1)
     * @param {number}          t             — Elapsed time in seconds
     *
     * Modes:
     *   Both same sinusoid  → uniform pulse (tips and valleys move together)
     *   Different sinusoids → tips and valleys breathe at different rates
     *   null for outer      → only valleys move; tips stay put
     *   null for inner      → only tips move; valleys stay put
     */
    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;
    }//end breathe

    /**
     * Animates rotation about the center.
     * @param {number} degPerSec — angular velocity in °/s (CCW positive)
     * @param {number} t         — elapsed time in seconds
     */
    rotateAboutCenter(degPerSec, t) {
        this._animRotDeg = degPerSec * t;
    }//end rotateAboutCenter

    /**
     * Convenience method: applies breathing and rotation in one call.
     * @param {Object} options — { sinusoidOuter, sinusoidInner, t, degPerSec }
     */
    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;
    }//end transform

    /** Resets radii, scales, rotation, and center position to original values. */
    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;
    }//end reset

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

    /** Returns the n outer-tip positions in user coordinates. */
    getOuterVerticesUserCoords() {
        return this._computeUserVertices().filter(v => v.isOuter);
    }//end getOuterVerticesUserCoords

    /** Returns the n inner-valley positions in user coordinates. */
    getInnerVerticesUserCoords() {
        return this._computeUserVertices().filter(v => !v.isOuter);
    }//end getInnerVerticesUserCoords

    /**
     * Returns true if screen point (px, py) is within `tolerance` pixels of
     * the star's center — used for drag detection.
     */
    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

    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 toString

}//end class SWStar
