/*
File: swEyeball.js
Date: 2026-02-27
Author: klp
Workspace: SWGhost2026-02-17-Stg1
Purpose: SWEyeball class for SketchWaveJS
Comment(s):

Encapsulates the full eye structure (sclera base, pupil/iris, top glint, bottom glint)
derived from the eye7Sketch.js development stage. Each eyeball manages its own
internal SWDisk and SWPoint components, including drag interaction for the pupil.

Constructor parameters follow the established SW class conventions:
  - eyeCenter    (SWPoint)  - center of the eyeball in user coordinates
  - radius       (number)   - radius of the sclera (white of the eye), in user units
  - baseThickness (number)  - stroke weight for the sclera border
  - eyeColors    (SWColor[2]) - [scleraFillColor, scleraBorderColor]
  - pupilFactor  (number)   - fraction of radius used for the pupil/iris disk (e.g. 0.4)
  - irisThickness (number)  - stroke weight for the iris (colored ring around the pupil)
  - pupilColors  (SWColor[2]) - [pupilFillColor, irisStrokeColor]

Internal glint geometry (matches eye7Sketch.js defaults, overridable via properties
BEFORE calling rebuild()):
  - glintFactor          : 0.4   (top glint radius = pupilRadius * glintFactor)
  - glintAngleDeg        : 135   (top-left highlight)
  - glintBottomAngleDeg  : 315   (bottom-right secondary highlight)
  - glintDistanceFactor  : 1.0   (top glint distance from pupil center = pupilRadius * factor)
  - glintBottomDistFactor: 0.7   (bottom glint distance from pupil center = pupilRadius * factor)
  - glintColor           : swWhite (set before rebuild() to change)

Interaction:
  - The pupil is draggable by default (isPupilDraggable = true).
  - Call handleMousePressed(), handleMouseDragged(), handleMouseReleased() from the
    p5.js event functions to enable interactive dragging.
  - handleMouseDragged() automatically constrains the pupil center so the pupil
    disk never escapes the sclera disk. The constraint radius is:
      maxPupilOffset = scleraRadius - pupilRadius
    At that distance the pupil's far edge exactly touches the sclera edge.
    This is stored as _maxPupilOffset (recomputed on every rebuild()).

Programmatic positioning (polar coordinates):
  - setPupilOffset(offsetRadius, angleDeg) places the pupil at a position
    described by polar coordinates centered at eyeCenter:
      offsetRadius : distance from eye center  (0 = centered; max = _maxPupilOffset)
      angleDeg     : direction, degrees CCW from positive X axis
                     (0° = right,  90° = up,  180° = left,  270° = down)
    offsetRadius is automatically clamped to [0, _maxPupilOffset].
  - pupilOffsetRadius and pupilOffsetAngleDeg are public properties that always
    reflect the current polar position — updated by both setPupilOffset() and drag.
  - The pupilOffset getter returns { radius, angleDeg } as a convenience object.
  - Offset survives rebuild(): _buildComponents() re-applies the stored offset, so
    programmatic look-direction is preserved through a full component reconstruction.

=== Notes ===:
- Assumes p5.js, SWColor, SWPoint, SWDisk, and SWGrid are loaded in the environment.
- Must be constructed AFTER colorMode is set and initializeSWColors() has been called
  (i.e., inside p5.js setup() or later), because glint offsets use p5.js cos()/radians().
- drawOnGrid() should be called each frame from draw().
- The eyeCenter SWPoint is shared with the eye base disk. The pupil uses its OWN
  SWPoint, allowing the pupil to be dragged independently of the sclera.
- Consistent API with SWDisk, SWLine, SWPoint, SWTriangle.

TODO:
- Add eyelid / blink animation
- Add breathe() method for sclera radius modulation via SWSinusoid
*/

console.log("[swEyeball.js] SWEyeball class loaded.");

class SWEyeball {

    /**
     * @param {SWPoint}   eyeCenter     - Center of the eyeball (user coordinates)
     * @param {number}    radius        - Sclera (white) radius in user units
     * @param {number}    baseThickness - Stroke weight for the sclera border
     * @param {SWColor[]} eyeColors     - [scleraFillColor, scleraBorderColor]
     * @param {number}    pupilFactor   - Fraction of radius for the pupil disk (e.g. 0.4)
     * @param {number}    irisThickness - Stroke weight for the iris ring
     * @param {SWColor[]} pupilColors   - [pupilFillColor, irisStrokeColor]
     */
    constructor(eyeCenter, radius, baseThickness, eyeColors, pupilFactor, irisThickness, pupilColors) {

        // ------- Store Construction Parameters -------

        /** @type {SWPoint} Center of the entire eyeball in user coordinates */
        this.eyeCenter = eyeCenter;

        /** @type {number} Sclera radius in user units */
        this.radius = radius;

        /** @type {number} Sclera border stroke weight */
        this.baseThickness = baseThickness;

        /** @type {SWColor} Fill color for the white sclera */
        this.scleraColor = eyeColors[0];

        /** @type {SWColor} Border/outline color for the sclera */
        this.borderColor = eyeColors[1];

        /** @type {number} Fraction of radius used for the pupil (0–1) */
        this.pupilFactor = pupilFactor;

        /** @type {number} Stroke weight for the iris ring drawn around the pupil */
        this.irisThickness = irisThickness;

        /** @type {SWColor} Fill color for the pupil (center dark area) */
        this.pupilColor = pupilColors[0];

        /** @type {SWColor} Stroke (iris ring) color drawn at the pupil boundary */
        this.irisColor = pupilColors[1];

        // ------- Glint Customization Properties -------
        // Override these BEFORE calling rebuild() to change glint appearance.

        /**
         * Top glint radius = pupilRadius * glintFactor
         * @type {number}
         */
        this.glintFactor = 0.4;

        /**
         * Color of both glint disks (default: white)
         * Set to any SWColor before calling rebuild().
         * @type {SWColor}
         */
        this.glintColor = swWhite;

        /**
         * Angle (degrees) for the top-left primary glint, measured from positive X axis.
         * 135° places the glint in the upper-left quadrant of the pupil.
         * @type {number}
         */
        this.glintAngleDeg = 135;

        /**
         * Angle (degrees) for the bottom-right secondary glint.
         * 315° places the secondary glint in the lower-right quadrant.
         * @type {number}
         */
        this.glintBottomAngleDeg = 315;

        /**
         * Distance multiplier for the top glint from the pupil center.
         * distance = pupilRadius * glintDistanceFactor
         * @type {number}
         */
        this.glintDistanceFactor = 1.0;

        /**
         * Distance multiplier for the bottom glint from the pupil center.
         * distance = pupilRadius * glintBottomDistFactor
         * @type {number}
         */
        this.glintBottomDistFactor = 0.7;

        // ------- Interaction Settings -------

        /**
         * Whether the pupil disk should be draggable.
         * Default: true. Set false before rebuild() to disable interaction.
         * @type {boolean}
         */
        this.isPupilDraggable = true;

        // ------- Pupil Offset Position (Polar) -------
        // These two properties describe where the pupil sits inside the sclera
        // using a polar coordinate system centered at eyeCenter.
        //
        // They default to (0, 0) so the pupil starts dead-center in the sclera.
        // After construction, call setPupilOffset(r, angle) to move the pupil
        // programmatically, or let interactive drag update them automatically.

        /**
         * Distance the pupil center is offset from eyeCenter, in user units.
         * Range: 0 (pupil centered) to _maxPupilOffset (edge of sclera boundary).
         * Kept in sync by both setPupilOffset() and handleMouseDragged().
         * @type {number}
         */
        this.pupilOffsetRadius = 0;

        /**
         * Direction of the pupil offset in degrees, CCW from positive X axis.
         *   0°  = right     90° = up
         *   180° = left    270° = down
         * Normalized to [0, 360) by both setPupilOffset() and handleMouseDragged().
         * @type {number}
         */
        this.pupilOffsetAngleDeg = 0;

        // ------- Internal Drag State -------
        /** @private */
        this._isDragging = false;

        // ------- Perlin Noise "Looking Around" Properties -------
        // These control the idle wandering behavior driven by lookingAround().
        // Designed to match the existing override-before-use pattern (like glintFactor).

        /**
         * Temporal rate of Perlin noise change — how fast the eye wanders per frame.
         * Lower = slower / more languid.  Higher = faster / more jittery.
         * Override at any time; no rebuild() needed.
         *   0.002 = dreamy/sleepy    0.005 = relaxed (default)    0.02 = alert/nervous
         * @type {number}
         */
        this.noiseSpeed = 0.005;

        /**
         * Internal noise time accumulator — advanced by noiseSpeed each lookingAround() call.
         * Reset to 0 when the eyeball is first constructed.
         * @private
         */
        this._noiseT = 0;

        /**
         * Random seed offset into the Perlin noise field for the X wandering channel.
         * Randomised at construction so different SWEyeball instances explore different
         * noise regions and wander independently, even if called identically.
         * @private
         */
        this._noiseOffsetX = random(0, 10000);

        /**
         * Random seed offset into the Perlin noise field for the Y wandering channel.
         * Offset by a large value from _noiseOffsetX to ensure statistical independence.
         * @private
         */
        this._noiseOffsetY = random(0, 10000);

        // ------- Build All Internal Components -------
        // Must be called LAST in the constructor (uses p5.js trig functions)
        this._buildComponents();

    }//end constructor

    // ============================================================================
    // PRIVATE: Component Construction
    // ============================================================================

    /**
     * Constructs all internal SWDisk and SWPoint objects.
     * Called once from the constructor.
     * Can also be called manually via rebuild() if glint properties are changed
     * after construction.
     * @private
     */
    _buildComponents() {
        const pupilRadius = this.radius * this.pupilFactor;

        // ------- Precompute Constraint Values -------
        // Store these so handleMouseDragged() can reference them without
        // recalculating every frame.

        /**
         * Pupil radius in user units — cached here so the drag constraint
         * always matches the disk actually drawn.
         * @private
         */
        this._pupilRadius = pupilRadius;

        /**
         * Maximum distance the pupil CENTER is allowed to travel from eyeCenter.
         *
         * Why (radius − pupilRadius) and not just radius?
         * ---------------------------------------------------------------------------
         * Imagine the sclera (white) as a large circle and the pupil as a smaller
         * circle sitting inside it.  If we let the pupil CENTER reach the sclera
         * EDGE, the pupil DISK would extend half-way outside the eye:
         *
         *   sclera edge  eye center     pupil center at limit
         *       │←── scleraRadius ────────→│←── pupilRadius ──→│
         *                                  ↑                    ↑
         *                          pupil center limit    pupil edge = sclera edge
         *
         * So the maximum legal center offset is (scleraRadius − pupilRadius).
         * At that distance, the far edge of the pupil disk exactly touches —
         * but does not cross — the sclera edge.
         * @private
         */
        this._maxPupilOffset = this.radius - pupilRadius;

        // ------- Compute Glint Offsets (user coordinates) -------

        // Top glint
        const topGlintRadius   = pupilRadius * this.glintFactor;
        const topGlintDist     = pupilRadius * this.glintDistanceFactor;
        const topOffsetX       = topGlintDist * cos(radians(this.glintAngleDeg));
        const topOffsetY       = topGlintDist * sin(radians(this.glintAngleDeg));

        // Bottom glint (smaller: factor squared, matching eye7Sketch.js)
        const bottomGlintRadius = pupilRadius * this.glintFactor * this.glintFactor;
        const bottomGlintDist   = pupilRadius * this.glintBottomDistFactor;
        const bottomOffsetX     = bottomGlintDist * cos(radians(this.glintBottomAngleDeg));
        const bottomOffsetY     = bottomGlintDist * sin(radians(this.glintBottomAngleDeg));

        // Store offsets so mouseDragged can update glints during a drag
        /** @private */ this._topOffsetX    = topOffsetX;
        /** @private */ this._topOffsetY    = topOffsetY;
        /** @private */ this._bottomOffsetX = bottomOffsetX;
        /** @private */ this._bottomOffsetY = bottomOffsetY;

        // ------- Eye Base (Sclera) -------
        /**
         * The outer white disk of the eye.
         * @type {SWDisk}
         */
        this.eyeBase = new SWDisk(
            this.eyeCenter,
            this.radius,
            this.baseThickness,
            this.scleraColor,
            this.borderColor
        );
        this.eyeBase.shouldShowCenter = false;

        // ------- Pupil / Iris -------
        // The pupil uses its OWN SWPoint (not eyeCenter) so it can move independently.
        const pupilCenter = new SWPoint(
            this.eyeCenter.x,
            this.eyeCenter.y,
            undefined,          // no Z
            16,                 // stroke weight for the center dot (hidden anyway)
            null,               // no explicit color
            "p"                 // label
        );

        /**
         * The iris/pupil disk. Its center (pupil.center) is the draggable point.
         * @type {SWDisk}
         */
        this.pupil = new SWDisk(
            pupilCenter,
            pupilRadius,
            this.irisThickness,
            this.pupilColor,
            this.irisColor
        );
        this.pupil.shouldShowCenter = false;

        // Apply draggable setting
        this.pupil.center.setDraggable(this.isPupilDraggable);

        // ------- Top Glint -------
        /**
         * SWPoint at the center of the top glint disk.
         * @type {SWPoint}
         */
        this.topGlintCenter = new SWPoint(
            pupilCenter.x + topOffsetX,
            pupilCenter.y + topOffsetY,
            undefined, 16, null, "tg"
        );

        /**
         * The primary (top-left) white highlight on the pupil.
         * @type {SWDisk}
         */
        this.topGlint = new SWDisk(
            this.topGlintCenter,
            topGlintRadius,
            1,
            this.glintColor,
            this.glintColor
        );
        this.topGlint.shouldShowCenter = false;

        // ------- Bottom Glint -------
        /**
         * SWPoint at the center of the bottom glint disk.
         * @type {SWPoint}
         */
        this.bottomGlintCenter = new SWPoint(
            pupilCenter.x + bottomOffsetX,
            pupilCenter.y + bottomOffsetY,
            undefined, 16, null, "bg"
        );

        /**
         * The secondary (bottom-right) white highlight on the pupil.
         * @type {SWDisk}
         */
        this.bottomGlint = new SWDisk(
            this.bottomGlintCenter,
            bottomGlintRadius,
            1,
            this.glintColor,
            this.glintColor
        );
        this.bottomGlint.shouldShowCenter = false;

        // ------- Apply Initial Pupil Offset -------
        // When the eyeball is first constructed, pupilOffsetRadius is 0, so this
        // block is skipped and the pupil starts at eyeCenter (centered). However,
        // if rebuild() is called AFTER setPupilOffset() was used, pupilOffsetRadius
        // will already be non-zero. Applying it here means the look-direction
        // survives a full _buildComponents() reconstruction.
        if (this.pupilOffsetRadius > 0) {
            const offsetX = this.pupilOffsetRadius * cos(radians(this.pupilOffsetAngleDeg));
            const offsetY = this.pupilOffsetRadius * sin(radians(this.pupilOffsetAngleDeg));
            this._applyPupilPosition(
                this.eyeCenter.x + offsetX,
                this.eyeCenter.y + offsetY
            );
        }

    }//end _buildComponents

    // ============================================================================
    // PRIVATE: Move Pupil & Glints to a User-Coordinate Position
    // ============================================================================

    /**
     * Moves the pupil center and both glint centers to the given user-coordinate
     * position.  This is the single authoritative place that physically repositions
     * the pupil assembly — shared by handleMouseDragged() and setPupilOffset() to
     * avoid duplicating the three setPosition() calls.
     *
     * Does NOT validate or clamp the position.  Callers are responsible for
     * ensuring userX/userY satisfy the constraint before calling.
     * See handleMouseDragged() and setPupilOffset() for clamping examples.
     *
     * @private
     * @param {number} userX - target pupil center X in user (grid) coordinates
     * @param {number} userY - target pupil center Y in user (grid) coordinates
     */
    _applyPupilPosition(userX, userY) {
        // Move the pupil disk's center SWPoint to the new position.
        this.pupil.center.setPosition(userX, userY);

        // Move both glints. They maintain fixed offsets from the pupil center
        // (_topOffsetX/Y and _bottomOffsetX/Y computed in _buildComponents),
        // so the specular highlights stay anchored relative to the iris shape
        // regardless of where the pupil travels.
        this.topGlintCenter.setPosition(
            userX + this._topOffsetX,
            userY + this._topOffsetY
        );
        this.bottomGlintCenter.setPosition(
            userX + this._bottomOffsetX,
            userY + this._bottomOffsetY
        );
    }//end _applyPupilPosition

    // ============================================================================
    // DRAWING
    // ============================================================================

    /**
     * Draws all eyeball components in user (grid) coordinates.
     * Call this each frame from draw().
     * Drawing order: base → pupil → top glint → bottom glint
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        this.eyeBase.drawOnGrid(grid);
        this.pupil.drawOnGrid(grid);
        this.topGlint.drawOnGrid(grid);
        this.bottomGlint.drawOnGrid(grid);
    }//end drawOnGrid

    /**
     * Draws all eyeball components in raw screen (pixel) coordinates.
     * Use drawOnGrid() unless you have a specific reason to bypass the grid.
     */
    draw() {
        this.eyeBase.draw();
        this.pupil.draw();
        this.topGlint.draw();
        this.bottomGlint.draw();
    }//end draw

    // ============================================================================
    // INTERACTION: Pupil Dragging
    // ============================================================================

    /**
     * Call from the p5.js mousePressed() function.
     * Checks whether the user clicked on the pupil and begins a drag if so.
     *
     * Returns true if this eyeball consumed the event — the caller should then
     * skip actions that would otherwise be triggered by a non-drag click (e.g.
     * toggling grid visibility).
     *
     * @param {number} mx        - p5.js mouseX (screen pixels)
     * @param {number} my        - p5.js mouseY (screen pixels)
     * @param {SWGrid} grid      - the active SWGrid (used for coordinate conversion)
     * @param {number} [tolerance=10] - hit-detection radius in screen pixels
     * @returns {boolean} true if drag started, false otherwise
     */
    handleMousePressed(mx, my, grid, tolerance = 10) {
        if (this.pupil.center.isDraggable &&
            this.pupil.center.containsPoint(mx, my, grid, tolerance)) {
            this._isDragging = true;
            console.log(`SWEyeball: started dragging pupil at (${this.pupil.center.x.toFixed(2)}, ${this.pupil.center.y.toFixed(2)})`);
            return true;  // event consumed
        }
        return false;
    }//end handleMousePressed

    /**
     * Call from the p5.js mouseDragged() function.
     * Updates the pupil position and keeps the two glints anchored at their
     * fixed offsets from the pupil center.
     *
     * Returns true if this eyeball is being dragged (allows the caller to skip
     * other drag logic and to return false to prevent default p5.js behavior).
     *
     * @param {number} mx   - p5.js mouseX (screen pixels)
     * @param {number} my   - p5.js mouseY (screen pixels)
     * @param {SWGrid} grid - the active SWGrid
     * @returns {boolean} true if this eyeball is being dragged
     */
    handleMouseDragged(mx, my, grid) {
        if (!this._isDragging) return false;

        // Convert screen coordinates to user (grid) coordinates
        const { x: rawX, y: rawY } = grid.screenToUser(mx, my);

        // ====================================================================
        // PUPIL CONSTRAINT — keep the pupil disk inside the sclera disk
        // ====================================================================
        // Goal: the pupil's edge must never cross the sclera's edge.
        //
        // Step 1 — Compute the displacement vector from the eye center to the
        //          raw (unconstrained) requested pupil position.
        const dx = rawX - this.eyeCenter.x;
        const dy = rawY - this.eyeCenter.y;

        // Step 2 — Compute how far the pupil center wants to be from eyeCenter.
        //          We use the Pythagorean theorem: dist = √(dx² + dy²)
        const dist = Math.sqrt(dx * dx + dy * dy);

        // Step 3 — Clamp if the requested distance exceeds the allowed maximum.
        //          _maxPupilOffset = scleraRadius − pupilRadius  (see _buildComponents)
        //
        //          If dist is within the allowed range, use rawX/rawY directly.
        //          If dist is too large, scale the vector back so its length equals
        //          _maxPupilOffset exactly — this keeps the direction the same
        //          (the pupil chases the mouse along the correct heading) but
        //          stops the disk edge at the sclera boundary.
        //
        //          Scaling formula:
        //            clampedX = eyeCenter.x + (dx / dist) * maxOffset
        //            clampedY = eyeCenter.y + (dy / dist) * maxOffset
        //
        //          The term (dx / dist) is the unit vector in the drag direction.
        //          Multiplying by maxOffset stretches it to exactly the right length.
        let userX, userY;
        if (dist > this._maxPupilOffset) {
            // Pupil would escape — project position back onto the constraint circle
            const scale = this._maxPupilOffset / dist;
            userX = this.eyeCenter.x + dx * scale;
            userY = this.eyeCenter.y + dy * scale;
        } else {
            // Still inside the sclera — no correction needed
            userX = rawX;
            userY = rawY;
        }
        // ====================================================================

        // Move the pupil and both glints to the (possibly clamped) position.
        // _applyPupilPosition() is the single place that relocates the whole
        // pupil assembly (pupil center + top glint + bottom glint).
        this._applyPupilPosition(userX, userY);

        // ----------------------------------------------------------------
        // Sync polar offset properties after every drag frame.
        // This lets sketch code query pupilOffsetRadius / pupilOffsetAngleDeg
        // at any time — not just after setPupilOffset() — to ask "where is
        // the pupil looking right now?"
        //
        // We compute from the final (clamped) position, not the raw mouse,
        // so the stored values are always within the legal range.
        //
        // Polar recovery:
        //   r = √(dx² + dy²)          (Pythagorean theorem)
        //   θ = atan2(dy, dx)         (four-quadrant angle)
        //   normalize θ to [0, 360)   so stored angle is always non-negative
        const finalDx = userX - this.eyeCenter.x;
        const finalDy = userY - this.eyeCenter.y;
        this.pupilOffsetRadius   = Math.sqrt(finalDx * finalDx + finalDy * finalDy);
        // Math.atan2 returns radians in [-π, π]. degrees() converts to [-180, 180].
        // Adding 360 then modding by 360 safely normalizes to [0, 360).
        this.pupilOffsetAngleDeg = (degrees(Math.atan2(finalDy, finalDx)) + 360) % 360;

        return true;  // event consumed
    }//end handleMouseDragged

    /**
     * Call from the p5.js mouseReleased() function.
     * Ends the drag operation for this eyeball.
     */
    handleMouseReleased() {
        if (this._isDragging) {
            console.log(`SWEyeball: released pupil at (${this.pupil.center.x.toFixed(2)}, ${this.pupil.center.y.toFixed(2)})`);
        }
        this._isDragging = false;
    }//end handleMouseReleased

    // ============================================================================
    // ACCESSORS / UTILITY
    // ============================================================================

    /**
     * True while the user is actively dragging the pupil.
     * @type {boolean}
     */
    get isDragging() {
        return this._isDragging;
    }

    /**
     * Returns the current pupil position as a polar offset from eyeCenter.
     * Stays in sync with both setPupilOffset() and interactive drag.
     *
     * @returns {{ radius: number, angleDeg: number }}
     *   radius   — current distance of pupil center from eyeCenter (user units)
     *   angleDeg — current direction in degrees CCW from positive X axis [0, 360)
     *
     * Example:
     *   const { radius, angleDeg } = leftEye.pupilOffset;
     *   console.log(`Pupil is ${radius.toFixed(2)} units away at ${angleDeg.toFixed(0)}°`);
     */
    get pupilOffset() {
        return {
            radius:   this.pupilOffsetRadius,
            angleDeg: this.pupilOffsetAngleDeg
        };
    }

    /**
     * Positions the pupil using polar coordinates relative to the eye center.
     *
     * Coordinate system — CCW from positive X axis (standard math convention):
     *
     *                  90° (up)
     *                     |
     *            135°  ---+---  45°
     *                     |
     *   180° (left) ------+------ 0° / 360° (right)
     *                     |
     *            225°  ---+---  315°
     *                     |
     *                  270° (down)
     *
     *   offsetRadius = 0               → pupil sits dead-center in sclera
     *   offsetRadius = _maxPupilOffset → pupil edge just touching sclera edge
     *
     * offsetRadius is automatically clamped to [0, _maxPupilOffset], so passing
     * a value larger than the maximum is safe — the pupil stops at the boundary,
     * matching the behavior of the interactive drag constraint.
     *
     * pupilOffsetRadius and pupilOffsetAngleDeg are updated to reflect the clamped
     * result, keeping all state consistent.
     *
     * Typical sketch usage:
     *   leftEye.setPupilOffset(0.8, 45);    // look up-right, 80% of max travel
     *   rightEye.setPupilOffset(0, 0);      // center the pupil
     *   leftEye.setPupilOffset(999, 270);   // look straight down (auto-clamped)
     *
     * @param {number} offsetRadius - Distance from eye center in user units.
     *                                Clamped to [0, _maxPupilOffset] automatically.
     * @param {number} angleDeg     - Direction CCW from positive X axis (degrees).
     *                                Normalized to [0, 360) automatically.
     */
    setPupilOffset(offsetRadius, angleDeg) {
        // Clamp radius to [0, _maxPupilOffset].
        // Math.max(0, ...) also guards against accidental negative inputs.
        const clampedRadius = Math.max(0, Math.min(offsetRadius, this._maxPupilOffset));

        // Normalize angle to [0, 360) so the stored value is always predictable.
        // The double-mod pattern handles negative angles correctly:
        //   e.g.  -90  → (-90 % 360 + 360) % 360 = 270
        //          450 → (450 % 360 + 360) % 360 =  90
        const normalizedAngle = ((angleDeg % 360) + 360) % 360;

        // Store the clamped / normalized polar coordinates as public properties.
        // These are the single source of truth for "where is the pupil looking?"
        // (also updated by handleMouseDragged so interactive drag stays in sync).
        this.pupilOffsetRadius   = clampedRadius;
        this.pupilOffsetAngleDeg = normalizedAngle;

        // Convert polar → Cartesian to find the target pupil center position.
        //
        //   Standard polar-to-Cartesian conversion:
        //     x = cx + r * cos(θ)
        //     y = cy + r * sin(θ)
        //
        //   where (cx, cy) = eyeCenter and θ is the angle in radians.
        //   p5.js cos() / radians() are used for consistency with the rest of
        //   the SketchWave codebase (vs Math.cos / Math.PI).
        const offsetX = clampedRadius * cos(radians(normalizedAngle));
        const offsetY = clampedRadius * sin(radians(normalizedAngle));

        const targetX = this.eyeCenter.x + offsetX;
        const targetY = this.eyeCenter.y + offsetY;

        // Apply: reposition the pupil center and both glints atomically.
        this._applyPupilPosition(targetX, targetY);

        console.log(`SWEyeball.setPupilOffset: r=${clampedRadius.toFixed(3)}, ` +
                    `θ=${normalizedAngle.toFixed(1)}° → ` +
                    `pupil at (${targetX.toFixed(3)}, ${targetY.toFixed(3)})`);
    }//end setPupilOffset

    /**
     * Points the pupil directly toward a target position in user (grid) coordinates.
     * Designed for high-frequency use inside draw() — no console logging.
     *
     * The pupil is always placed at maximum offset (_maxPupilOffset) in the
     * direction of the target, so the eye always looks "fully" toward the target.
     * Glints follow automatically via _applyPupilPosition().
     *
     * When two eyes independently call lookAt() with the same target, each
     * computes the angle FROM its own eyeCenter — so eyes at different positions
     * derive slightly different gaze angles. This natural "vergence" difference
     * produces the comical wonky look when eyes are widely spaced.
     *
     * Companion method lookAtAngle(angleDeg) is available when a shared angle
     * has already been computed (e.g., Track-ON mode in the sketch).
     *
     * @param {number} targetUserX - X of look-at target in user (grid) coordinates
     * @param {number} targetUserY - Y of look-at target in user (grid) coordinates
     */
    lookAt(targetUserX, targetUserY) {
        const dx = targetUserX - this.eyeCenter.x;
        const dy = targetUserY - this.eyeCenter.y;

        // atan2(dy, dx) gives standard math angle (CCW from +X).
        // degrees() converts radians → degrees. Normalize to [0, 360).
        const angleDeg = ((degrees(Math.atan2(dy, dx)) % 360) + 360) % 360;

        // Always use full travel so gaze direction is clearly visible.
        this.pupilOffsetRadius   = this._maxPupilOffset;
        this.pupilOffsetAngleDeg = angleDeg;

        // Convert polar → Cartesian and physically move the pupil assembly.
        const rad   = radians(angleDeg);
        const userX = this.eyeCenter.x + this._maxPupilOffset * cos(rad);
        const userY = this.eyeCenter.y + this._maxPupilOffset * sin(rad);
        this._applyPupilPosition(userX, userY);
    }//end lookAt

    /**
     * Points the pupil in the given direction at maximum offset.
     * Companion to lookAt() for use when a shared gaze angle has already been
     * computed externally (Track-ON + mouse-follow mode). No console logging.
     *
     * @param {number} angleDeg - Direction CCW from positive X axis (degrees).
     *                            Normalized to [0, 360) automatically.
     */
    lookAtAngle(angleDeg) {
        const normalizedAngle = ((angleDeg % 360) + 360) % 360;

        this.pupilOffsetRadius   = this._maxPupilOffset;
        this.pupilOffsetAngleDeg = normalizedAngle;

        const rad   = radians(normalizedAngle);
        const userX = this.eyeCenter.x + this._maxPupilOffset * cos(rad);
        const userY = this.eyeCenter.y + this._maxPupilOffset * sin(rad);
        this._applyPupilPosition(userX, userY);
    }//end lookAtAngle

    /**
     * Animates the pupil with natural, smooth random wandering using 2-D Perlin noise.
     * Designed as the default idle behavior — call every frame from draw() when the
     * eye is neither following the mouse nor being dragged.
     *
     * Two independent Cartesian noise channels (X and Y, sampled at offset positions
     * in the noise field) produce a smooth, organic 2-D random walk.  Each SWEyeball
     * instance has its own random _noiseOffsetX / _noiseOffsetY set at construction,
     * so two eyes called with identical parameters still wander independently.
     *
     * ── Parameter design ──────────────────────────────────────────────────────────
     * 'strength' (amplitude) and noise pace (speed) are intentionally separate:
     *
     *   strength — call-site parameter: HOW FAR the pupil travels (0–1 fraction
     *              of _maxPupilOffset).  0 = centered; 1 = full sclera-edge travel.
     *
     *   this.noiseSpeed — instance property: HOW FAST the noise advances per frame.
     *                     Set it before calling, like any other swEyeball property.
     *                     Low (0.002) = languid/sleepy.  High (0.02) = alert/nervous.
     *
     * This mirrors the existing pattern of glintFactor, glintDistanceFactor, etc.
     * ──────────────────────────────────────────────────────────────────────────────
     *
     * For Track mode: call lookingAround() on the primary eye, then mirror its
     * pupilOffsetRadius / pupilOffsetAngleDeg to the other eye via setPupilOffset().
     *
     * @param {number} [strength=0.8] - Amplitude fraction of _maxPupilOffset [0–1].
     *                                  Values above 1 are clamped to 1.
     */
    lookingAround(strength = 0.8) {
        // Advance the noise clock by the configurable per-frame speed.
        this._noiseT += this.noiseSpeed;

        // Clamp strength to [0, 1] — values outside that range make no physical sense.
        const s    = Math.max(0, Math.min(1, strength));
        const maxR = this._maxPupilOffset * s;

        if (maxR < 0.0001) {
            // Effectively zero strength — snap to center and return.
            this._applyPupilPosition(this.eyeCenter.x, this.eyeCenter.y);
            this.pupilOffsetRadius   = 0;
            this.pupilOffsetAngleDeg = 0;
            return;
        }

        // ── Sample two independent Perlin noise channels ──────────────────────────
        // noise() returns a smooth value in [0, 1].  We map each to [-1, 1] and
        // scale by maxR to get a Cartesian displacement vector.
        //
        // The two channels use different offsets in the noise field:
        //   X channel: this._noiseT + this._noiseOffsetX
        //   Y channel: this._noiseT + this._noiseOffsetY
        // Because _noiseOffsetX and _noiseOffsetY are randomised independently at
        // construction, each axis follows a different noise trajectory and the two
        // eyes on-screen wander in uncorrelated directions — like real eyes.
        const rawX = map(noise(this._noiseT + this._noiseOffsetX), 0, 1, -1, 1) * maxR;
        const rawY = map(noise(this._noiseT + this._noiseOffsetY), 0, 1, -1, 1) * maxR;

        // ── Clamp to the sclera constraint circle (robustness) ────────────────────
        // Perlin noise centers around 0.5, so the Cartesian vector rarely exceeds
        // maxR, but we clamp here for safety — matching the drag-constraint logic.
        const dist = Math.sqrt(rawX * rawX + rawY * rawY);
        let finalX, finalY;
        if (dist > maxR && dist > 0) {
            const scale = maxR / dist;
            finalX = rawX * scale;
            finalY = rawY * scale;
        } else {
            finalX = rawX;
            finalY = rawY;
        }

        const userX = this.eyeCenter.x + finalX;
        const userY = this.eyeCenter.y + finalY;

        // Sync polar-offset properties so Track mode and external code can read
        // the current gaze direction via pupilOffsetRadius / pupilOffsetAngleDeg.
        this.pupilOffsetRadius   = Math.sqrt(finalX * finalX + finalY * finalY);
        this.pupilOffsetAngleDeg = ((degrees(Math.atan2(finalY, finalX)) + 360) % 360);

        this._applyPupilPosition(userX, userY);
    }//end lookingAround

    /**
     * Sets the fill alpha transparency of the eye base (sclera).
     * Useful during development to reveal layers underneath.
     * @param {number} alpha - 0 (transparent) to 100 (opaque)
     */
    setBaseAlpha(alpha) {
        this.eyeBase.setFillAlpha(alpha);
    }//end setBaseAlpha

    /**
     * Changes the iris (ring) color live — no rebuild required.
     *
     * The iris is the colored stroke drawn around the pupil disk boundary.
     * It is stored as this.pupil.strokeColor (SWColor) inside the SWDisk.
     * Because SWDisk makes its own copy on construction (SWColor.copy),
     * we update it the same way: replace with a fresh copy of the new color.
     *
     * Also updates this.irisColor so that any future rebuild() call picks up
     * the current color instead of reverting to the construction-time value.
     *
     * @param {SWColor} swColor - New iris ring color (SWColor instance)
     */
    setIrisColor(swColor) {
        // Keep the eyeball's own record in sync — used by _buildComponents().
        this.irisColor = swColor;

        // Directly replace the stroke color on the live SWDisk instance.
        // SWColor.copy() creates an independent copy so external mutations to
        // swColor won't silently affect this eye later.
        this.pupil.strokeColor = SWColor.copy(swColor);

        console.log(`SWEyeball.setIrisColor: ${swColor.toString()}`);
    }//end setIrisColor

    /**
     * Rebuilds all internal components after glint properties have been changed.
     * Call this if you modify glintFactor, glintColor, glintAngleDeg, etc. after
     * the eyeball has already been constructed.
     *
     * Note: the drag state is reset when rebuild() is called.
     */
    rebuild() {
        this._isDragging = false;
        this._buildComponents();
    }//end rebuild

    /**
     * Returns a concise string description of the eyeball.
     * @returns {string}
     */
    toString() {
        return `SWEyeball(center: ${this.eyeCenter.toString()}, radius: ${this.radius}, ` +
               `pupilFactor: ${this.pupilFactor}, ` +
               `pupilOffset: r=${this.pupilOffsetRadius.toFixed(3)} θ=${this.pupilOffsetAngleDeg.toFixed(1)}°, ` +
               `dragging: ${this._isDragging})`;
    }//end toString

}//end class SWEyeball
