◐ SWTwoTonedDisk Reference

A SketchWave composite class for animated two-toned disks — half blue, half red, all yours

SWTwoTonedDisk Demo

Quick Reference

SWTwoTonedDisk is a SketchWave composite class for a disk divided into two 180° half-circles that share a common diameter. Each half is rendered as a filled SWSector (for the interior) plus an SWArc (for the curved circumference). The fill and arc outline colors are independently controllable for each half. The top and bottom radii can differ, producing asymmetric "lune" shapes. The disk supports spin, radius breathing, and hue-cycling animations.

  • Extends: Nothing (standalone composite class)
  • Dependencies: SWPoint, SWColor, SWSinusoid, SWGrid, SWSector, SWArc, p5.js
  • Composition: top SWSector + bottom SWSector + top SWArc + bottom SWArc
  • Key Features: Independent fill & arc outline colors per half, asymmetric radii, spin, breathe, hue cycling, drag-to-reposition center
  • Common Uses: Yin-yang–style symbols, team logos, animated badges, planetary half-tone effects, loading indicators

Overview

Composition Model

A SWTwoTonedDisk is built from four sub-objects, created and managed internally:

Sub-objectRoleColor used
topSectorFilled upper half-circle (0° → 180° CCW)topFillColor
bottomSectorFilled lower half-circle (180° → 360° CCW)bottomFillColor
topArcCurved circumference outline for upper halftopArcColor
bottomArcCurved circumference outline for lower halfbottomArcColor

The sectors use zero stroke weight — no border is drawn on the shared diameter line, which would produce an unwanted center seam. The arcs use capStyle='butt' so their endpoints are perfectly flush at the diameter, with no round-cap bumps.

Sub-objects are synchronized automatically — you never need to touch topSector, topArc, etc. directly. Use the setter methods and they propagate changes internally.

Color Model

Each half has two independent colors:

  • Fill color (topFillColor / bottomFillColor) — controls the sector interior only. Set via setTopFillColor() / setBottomFillColor().
  • Arc color (topArcColor / bottomArcColor) — controls the circumference arc stroke only. Set via setTopArcColor() / setBottomArcColor().

At construction, arc colors default to matching the fill colors. You can change them independently afterward. Setting setTopFillColor() does not update the arc color and vice versa.

Angle and Diameter Convention

SWTwoTonedDisk uses the same math-space (user-space) angle convention as SWSector and SWArc: angles are measured counterclockwise (CCW) from the positive x-axis, and y-values increase upward. All angles are converted to p5's y-down screen space internally.

// Default orientation: diameter along the +x axis.
// Top half fills Q1 + Q2 (upper), bottom half fills Q3 + Q4 (lower).
let disk = new SWTwoTonedDisk(new SWPoint(0, 0), 5);

// startAngle=90: diameter is now vertical (+y axis).
// Top half (formerly upper) has rotated 90° CCW to the left side.
let disk2 = new SWTwoTonedDisk(new SWPoint(0, 0), 5, 90);
  • startAngle: static orientation of the shared diameter (set once at construction or via setStartAngle())
  • rotation: accumulated spin added by each rotate(delta) call
  • Effective orientation = startAngle + rotation

Asymmetric Radii (Lune Shapes)

The top and bottom halves can have independently sized radii. When they differ, the halves form a "lune" — one half bulges while the other is smaller. Both halves still share the same center and diameter orientation.

disk.setTopRadius(7);    // top half is larger
disk.setBottomRadius(4); // bottom half is smaller → lune shape

Typical Workflow

  1. Create an SWTwoTonedDisk with center, radius, startAngle, top/bottom colors, and arc thickness
  2. Call drawOnGrid(grid) each frame inside draw()
  3. For animations, each frame:
    • Call rotate(delta) before draw (takes effect immediately)
    • Call breathe(sinusoid, t) once per frame (before or after draw)
    • For hue cycling, mutate fill color HSB values and push via setTopFillColor() before draw
  4. Use the elapsed-time pattern for pauseable animations
  5. Call reset() to restore all geometry and colors to construction-time originals

Constructor

new SWTwoTonedDisk(center, radius, startAngle, topFillColor, bottomFillColor, arcThickness)

Creates a new SWTwoTonedDisk and builds all four internal sub-objects. Arc outline colors default to matching the fill colors at construction.

Parameters
ParameterTypeDefaultDescription
center SWPoint required Center of the disk. Can include a display color and pixel size for the center marker.
radius number required Initial radius for both halves in user units (> 0). Use setters to make them differ afterward.
startAngle number 0 Orientation of the shared diameter in degrees CCW from the +x axis.
topFillColor SWColor undefined Fill color for the top half sector. Also sets the initial arc color for the top half (independent afterward).
bottomFillColor SWColor undefined Fill color for the bottom half sector. Also sets the initial arc color for the bottom half.
arcThickness number 4 Stroke weight in pixels for both circumference arcs.
Constructor Examples
// Minimal: just a center and radius (no colors — gray or invisible)
let disk1 = new SWTwoTonedDisk(new SWPoint(0, 0), 5);

// With colors and default startAngle
let topColor    = SWColor.fromHex('#0066CC', 'top').setAlphaTo(90);
let bottomColor = SWColor.fromHex('#CC0000', 'bot').setAlphaTo(90);
let disk2 = new SWTwoTonedDisk(new SWPoint(0, 0), 5, 0, topColor, bottomColor);

// Rotated 45°: diameter points diagonally up-right
let disk3 = new SWTwoTonedDisk(new SWPoint(0, 0), 5, 45, topColor, bottomColor, 6);

// Styled center marker (dark gray, 8px)
const centerPt = new SWPoint(0, 0, undefined, 8, swDarkGray);
let disk4 = new SWTwoTonedDisk(centerPt, 5, 0, topColor, bottomColor, 4);

Properties

centerSWPoint

The center of the disk. Drag it in the demo to reposition the disk live. All four sub-objects sync their positions from this point each frame.

topRadiusnumber

Radius of the top half in user units. Set via setTopRadius(r). Can differ from bottomRadius to create lune shapes.

bottomRadiusnumber

Radius of the bottom half in user units. Set via setBottomRadius(r).

startAnglenumber

Static diameter orientation in degrees CCW from the +x axis. Set once at construction or via setStartAngle(). Does not change when rotate() is called — that accumulates in rotation separately.

rotationnumber

Accumulated rotation in degrees (CCW positive). Added to startAngle when rendering. Incremented each frame by rotate(delta).

arcThicknessnumber

Stroke weight in pixels applied to both circumference arcs. Set via setArcThickness(w).

Top Half Color Properties

topFillColorSWColor

Fill color for the top SWSector interior only. Set via setTopFillColor() or setTopAlpha(). Does not affect topArcColor.

topArcColorSWColor

Stroke color for the top circumference arc, independent of the fill. Defaults to matching topFillColor at construction. Set via setTopArcColor() or setTopArcAlpha().

Bottom Half Color Properties

bottomFillColorSWColor

Fill color for the bottom SWSector interior only. Set via setBottomFillColor() or setBottomAlpha().

bottomArcColorSWColor

Stroke color for the bottom circumference arc, independent of the fill. Defaults to matching bottomFillColor at construction. Set via setBottomArcColor() or setBottomArcAlpha().

shouldShowCenterboolean

When true (default), the center SWPoint is rendered as a visible marker. Toggle with setShowCenter(show).

Original-State Properties (used by reset())

Set from constructor arguments; used to restore the disk to its initial state. Do not modify directly.

  • originalTopRadius
  • originalBottomRadius
  • originalStartAngle
  • originalArcThickness
  • originalTopFillColor (deep copy of topFillColor at construction)
  • originalBottomFillColor (deep copy of bottomFillColor at construction)
  • originalTopArcColor (deep copy of topFillColor at construction)
  • originalBottomArcColor (deep copy of bottomFillColor at construction)

Methods

Drawing

draw()

Draws all four sub-objects in screen (pixel) coordinates, using the center's raw x/y as pixel positions. Use drawOnGrid() for user-coordinate rendering.

drawOnGrid(grid)

Draws all four sub-objects mapped through the given SWGrid. Centre position and radii are converted from user units to screen pixels via the grid's scale and offset. This is the standard call for demos that use an SWGrid.

// Typical in draw():
grid.draw();
disk1.drawOnGrid(grid);

Rotation Animation

rotate(deltaAngle)

Increments rotation by deltaAngle degrees (CCW positive, CW negative). Call before draw each frame to spin the disk about its center. Both halves always rotate together.

ParameterTypeDescription
deltaAnglenumberDegrees to add to rotation; negative = clockwise
// In draw(): spin at 45 deg/sec
disk1.rotate(45 * deltaT);   // deltaT = seconds since last frame
disk1.drawOnGrid(grid);

Breathing Animation

breathe(sinusoid, t)

Sets both topRadius and bottomRadius to the same sinusoid value, making both halves grow and shrink in unison. Minimum clamped to 0.01. For independent radius breathing, call setTopRadius() and setBottomRadius() manually with separate sinusoids.

ParameterTypeDescription
sinusoidSWSinusoidConfigured with desired period and min/max radius values
tnumberElapsed animated time in seconds
// Setup (once in setup()):
breatheSinusoid = SWSinusoid.copy(UNIT_SINUSOID);
breatheSinusoid.setPeriod(4);
breatheSinusoid.adjustWaveUsingExtrema(2, 8);   // radius oscillates 2–8 units

// In draw():
if (shouldBreathe) {
    const bt = breatheElapsed + (millis()/1000 - breatheStartTime);
    disk1.breathe(breatheSinusoid, bt);
}

Radius Setters

setTopRadius(r)

Sets the top half's radius in user units and propagates the change to topSector and topArc.

setBottomRadius(r)

Sets the bottom half's radius in user units and propagates the change to bottomSector and bottomArc.

Fill Color Methods

setTopFillColor(swColor)

Sets the top sector's fill color. Deep-copies the provided SWColor; does not touch topArcColor.

setBottomFillColor(swColor)

Sets the bottom sector's fill color. Does not touch bottomArcColor.

setTopAlpha(alpha)

Sets the top fill color's alpha, clamped to [0, 100]. Rebuilds the p5 color object and propagates to the top sector only. Does not affect the top arc.

setBottomAlpha(alpha)

Sets the bottom fill color's alpha, clamped to [0, 100]. Does not affect the bottom arc.

Arc Outline Color Methods

setTopArcColor(swColor)

Sets the top circumference arc's stroke color, independently of the fill. Deep-copies the provided SWColor.

// Give the top arc a contrasting gold outline:
let goldArc = SWColor.fromHex('#FFD700', 'gold').setAlphaTo(100);
disk1.setTopArcColor(goldArc);

setBottomArcColor(swColor)

Sets the bottom circumference arc's stroke color independently of the fill.

setTopArcAlpha(alpha)

Sets the top arc's outline alpha, clamped to [0, 100]. Rebuilds the p5 color object and propagates to the top arc only.

setBottomArcAlpha(alpha)

Sets the bottom arc's outline alpha, clamped to [0, 100].

Arc Thickness

setArcThickness(w)

Sets the stroke weight (pixels) for both circumference arcs. Also updates the internal pixelRadiusOffset on each arc to prevent alpha overlap with the sector fill — the arc is shifted outward by w/2 pixels so its inner edge is flush with the sector's outer boundary.

ParameterTypeDescription
wnumberStroke weight in pixels (≥ 0). 0 = invisible arcs.

Other Setters

setStartAngle(degrees)

Sets the static diameter orientation (CCW from +x axis) without affecting accumulated rotation.

setShowCenter(show)

Shows or hides the center marker point. Pass true (default) to show, false to hide.

Reset Methods

reset()

Restores all animated/slider-driven values — both radii, startAngle, rotation, arcThickness, all four colors — to the originals stored at construction. Does not move the center position.

resetTopColor()

Restores only the top fill color to its construction-time original, without touching the bottom half or arc colors.

resetBottomColor()

Restores only the bottom fill color to its construction-time original.

Static Methods

static copy(other)

Returns a deep copy of the given SWTwoTonedDisk instance. Deep-copies the center, all four colors, and geometry. The copy has the same current radii and rotation as the original. Throws if the argument is not an SWTwoTonedDisk.

let diskCopy = SWTwoTonedDisk.copy(disk1);

Utility Methods

toString()

Returns a human-readable description of the disk's current state.

console.log(disk1.toString());
// → SWTwoTonedDisk(center: SWPoint(0.00, 0.00), topRadius: 5.00,
//   bottomRadius: 5.00, startAngle: 0°, rotation: 0.0°, arcThickness: 4)

Animation Guide

Elapsed-Time Pattern (Pause/Resume)

All continuous animations use the same pattern: accumulate "elapsed" seconds while paused, so the wave doesn't jump when you resume.

// Globals:
let shouldBreathe = false;
let breatheStartTime = 0, breatheElapsed = 0;

// Toggle:
function toggleBreathe() {
    const t = millis() / 1000;
    if (!shouldBreathe) {
        breatheStartTime = t;
        shouldBreathe    = true;
    } else {
        breatheElapsed  += (t - breatheStartTime);
        shouldBreathe    = false;
    }
}

// In draw():
if (shouldBreathe) {
    const bt = breatheElapsed + (millis()/1000 - breatheStartTime);
    disk1.breathe(breatheSinusoid, bt);
}

Spin Animation

// Globals:
let shouldSpin = false;
let spinSpeed  = 45;   // degrees per second (CCW)

// In draw():
const t      = millis() / 1000;
const deltaT = (prevT > 0) ? (t - prevT) : 0;
prevT = t;

if (shouldSpin) disk1.rotate(spinSpeed * deltaT);
disk1.drawOnGrid(grid);

Hue Cycling (Fill Colors)

Hue cycling is implemented externally: compute the hue from a sinusoid, rebuild the SWColor's p5 color, then push it to the disk. Note: setTopFillColor() only updates the sector fill — the arc color stays independent unless you also update it.

// In draw() — cycle top + bottom fill hues:
if (shouldCycleHue) {
    const ht = hueCycleElapsed + (millis()/1000 - hueCycleStartTime);

    // Top fill hue
    topColor.h   = hueSinusoid.getValue(ht);
    topColor.col = color(topColor.h, topColor.s, topColor.b, topColor.a);
    disk1.setTopFillColor(topColor);

    // Bottom fill hue (offset from top for complementary effect)
    bottomColor.h   = (topColor.h + 180) % 360;
    bottomColor.col = color(bottomColor.h, bottomColor.s, bottomColor.b, bottomColor.a);
    disk1.setBottomFillColor(bottomColor);
}
disk1.drawOnGrid(grid);

Running All Animations Simultaneously

// In draw() — spin + hue BEFORE draw; breathe can be before or after:
const t      = millis() / 1000;
const deltaT = (prevT > 0) ? (t - prevT) : 0;
prevT = t;

background(bgColor);
if (shouldShowGrid) grid.draw();

// 1. Spin
if (shouldSpin) disk1.rotate(spinSpeed * deltaT);

// 2. Hue cycle (fill colors only)
if (shouldCycleHue) {
    const ht = hueCycleElapsed + (t - hueCycleStartTime);
    topColor.h   = hueSinusoid.getValue(ht);
    topColor.col = color(topColor.h, topColor.s, topColor.b, topColor.a);
    disk1.setTopFillColor(topColor);
    bottomColor.h   = (topColor.h + 180) % 360;
    bottomColor.col = color(bottomColor.h, bottomColor.s, bottomColor.b, bottomColor.a);
    disk1.setBottomFillColor(bottomColor);
}

// 3. Breathe
if (shouldBreathe) {
    const bt = breatheElapsed + (t - breatheStartTime);
    disk1.breathe(breatheSinusoid, bt);
}

// 4. Draw
disk1.drawOnGrid(grid);

Usage Examples

Minimal Disk

let disk, grid;

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

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

    const topColor    = SWColor.fromHex('#0066CC', 'top').setAlphaTo(90);
    const bottomColor = SWColor.fromHex('#CC0000', 'bot').setAlphaTo(90);
    disk = new SWTwoTonedDisk(new SWPoint(0, 0), 5, 0, topColor, bottomColor, 4);
}

function draw() {
    background(240, 5, 93);
    grid.draw();
    disk.drawOnGrid(grid);
}

Asymmetric Lune

// Top half is larger than the bottom — creates a "lune" appearance.
disk.setTopRadius(7);
disk.setBottomRadius(3);

Spinning Disk (Time-Based)

let prevT = 0;
const SPIN_SPEED = 60; // degrees per second

function draw() {
    const t      = millis() / 1000;
    const deltaT = (prevT > 0) ? (t - prevT) : 0;
    prevT = t;

    background(240, 5, 93);
    grid.draw();
    disk.rotate(SPIN_SPEED * deltaT);
    disk.drawOnGrid(grid);
}

Breathing Radius

let breatheSin;
let breatheStart = 0, breatheElapsed = 0, shouldBreathe = false;

function setup() {
    // ...disk construction...
    breatheSin = SWSinusoid.copy(UNIT_SINUSOID);
    breatheSin.setPeriod(4);
    breatheSin.adjustWaveUsingExtrema(2, 8);
}

function draw() {
    const t = millis() / 1000;
    // ...
    if (shouldBreathe) {
        disk.breathe(breatheSin, breatheElapsed + (t - breatheStart));
    }
    disk.drawOnGrid(grid);
}

function keyPressed() {
    if (key === 'b') {
        const t = millis() / 1000;
        if (!shouldBreathe) { breatheStart = t; shouldBreathe = true; }
        else { breatheElapsed += t - breatheStart; shouldBreathe = false; }
    }
    if (key === 'r') { disk.reset(); breatheElapsed = 0; }
}

Independent Arc and Fill Colors

// Fill colors (sector interior)
const topFill    = SWColor.fromHex('#0066CC', 'topFill').setAlphaTo(80);
const bottomFill = SWColor.fromHex('#CC0000', 'botFill').setAlphaTo(80);
disk = new SWTwoTonedDisk(new SWPoint(0, 0), 5, 0, topFill, bottomFill, 6);

// Override arc colors independently — gold outlines on both halves
const goldArc = SWColor.fromHex('#FFD700', 'gold').setAlphaTo(100);
disk.setTopArcColor(goldArc);
disk.setBottomArcColor(goldArc);

// Adjust arc opacity without changing color
disk.setTopArcAlpha(60);

Loading Dependencies

<!-- p5.js -->
<script src="https://cdn.jsdelivr.net/npm/p5@1.6.0/lib/p5.js"></script>

<!-- SketchWaveJS classes (order matters) -->
<script src="shapeClasses/swSinusoid.js"></script>
<script src="shapeClasses/swColor.js"></script>
<script src="shapeClasses/swPoint.js"></script>
<script src="shapeClasses/swGrid.js"></script>
<script src="shapeClasses/swSector.js"></script>
<script src="shapeClasses/swArc.js"></script>
<script src="shapeClasses/swTwoTonedDisk.js"></script>

<!-- Your sketch -->
<script src="sketches/yourSketch.js"></script>

Source Code

The full swTwoTonedDisk.js source code is shown below for reference.

/*
File: swTwoTonedDisk.js
Date: 2026-03-29
Author: klp
App:  SketchWaveTNT2026-03-19-Stg7
Purpose: SWTwoTonedDisk class for SketchWaveJS

SWTwoTonedDisk represents a disk divided into two 180° half-circles sharing a
common diameter.  Each half is composed of:
  - An SWSector (filled, zero stroke weight — a border on the shared diameter
    would create an unwanted center line where the two halves meet)
  - An SWArc   (stroke only, butt caps) that outlines the curved circumference
    of its half, in the same color as the fill

Default orientation: the shared diameter lies along the +x axis (top half opens
into the upper half-plane, bottom half opens into the lower half-plane).
Use startAngle to pre-rotate this orientation.  Calls to rotate() accumulate
additional spin on top of startAngle.

Animations:
  - rotate(delta)      : spin both halves together about the center (CCW positive).
  - breathe(sin, t)    : oscillate BOTH radii with the same SWSinusoid.
                         Both halves reach equal radius during breathing.
                         Use setTopRadius / setBottomRadius for independent control.

Top radius and bottom radius can differ — set independently via setTopRadius /
setBottomRadius to create asymmetric "lune" shapes.

Color model:
  Each half has TWO independent colors:
    - Fill color  (topFillColor / bottomFillColor)  → sector interior only
    - Arc color   (topArcColor  / bottomArcColor)   → circumference arc only
  Arc colors default to matching the fill colors at construction.
  Use setTopArcColor / setBottomArcColor to change them independently.

Angle / rotation convention (same as SWSector and SWArc):
  User space:   CCW from +x axis, y increases upward.
  p5 / screen:  CW  from +x axis, y increases downward.
  Effective sub-object rotation = this.startAngle + this.rotation.

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

console.log("[swTwoTonedDisk.js] SWTwoTonedDisk class loaded.");

class SWTwoTonedDisk {

    /**
     * @param {SWPoint} center            - Center of the disk (SWPoint)
     * @param {number}  radius            - Initial radius for both halves (user units, > 0)
     * @param {number}  [startAngle=0]    - Static diameter orientation (degrees CCW from +x axis)
     * @param {SWColor} [topFillColor]    - Color for the top half sector fill
     * @param {SWColor} [bottomFillColor] - Color for the bottom half sector fill
     * @param {number}  [arcThickness=4]  - Stroke weight (pixels) for both circumference arcs
     */
    constructor(center, radius, startAngle = 0,
                topFillColor = undefined, bottomFillColor = undefined,
                arcThickness = 4) {

        this.center       = center;
        this.topRadius    = radius;
        this.bottomRadius = radius;
        this.startAngle   = startAngle;   // static orientation offset
        this.rotation     = 0;            // accumulated via rotate()
        this.arcThickness = arcThickness;

        // Always copy colors to avoid mutating shared SWColor objects
        this.topFillColor    = topFillColor    ? SWColor.copy(topFillColor)    : undefined;
        this.bottomFillColor = bottomFillColor ? SWColor.copy(bottomFillColor) : undefined;

        // Arc outline colors — default to matching the fill color.
        // Set independently with setTopArcColor / setBottomArcColor.
        this.topArcColor    = topFillColor    ? SWColor.copy(topFillColor)    : undefined;
        this.bottomArcColor = bottomFillColor ? SWColor.copy(bottomFillColor) : undefined;

        // Originals stored at construction — used by reset()
        this.originalTopRadius       = radius;
        this.originalBottomRadius    = radius;
        this.originalStartAngle      = startAngle;
        this.originalArcThickness    = arcThickness;
        this.originalTopFillColor    = topFillColor    ? SWColor.copy(topFillColor)    : undefined;
        this.originalBottomFillColor = bottomFillColor ? SWColor.copy(bottomFillColor) : undefined;
        this.originalTopArcColor     = topFillColor    ? SWColor.copy(topFillColor)    : undefined;
        this.originalBottomArcColor  = bottomFillColor ? SWColor.copy(bottomFillColor) : undefined;

        this.shouldShowCenter = true;

        this._buildSubObjects();
    }//end constructor

    // ─── Internal Construction ─────────────────────────────────────────────────

    /**
     * Creates the four sub-objects that compose the disk.
     * Called once at construction; do not call again (use setters).
     */
    _buildSubObjects() {
        const cx = this.center.x, cy = this.center.y;

        // ── Top sector: 0° → 180° CCW (upper half), no stroke border ───────────
        this.topSector = new SWSector(
            new SWPoint(cx, cy), this.topRadius, 180, 0,
            0,                              // thickness = 0 → no border on diameter
            this.topFillColor, undefined    // no strokeColor
        );
        this.topSector.setShowVertex(false);

        // ── Bottom sector: 180° → 360° CCW (lower half), no stroke border ──────
        this.bottomSector = new SWSector(
            new SWPoint(cx, cy), this.bottomRadius, 180, 180,
            0,
            this.bottomFillColor, undefined
        );
        this.bottomSector.setShowVertex(false);

        // ── Top arc: curved circumference of the upper half, butt caps ──────────
        //    'butt' caps keep the arc endpoints flush with the diameter endpoints,
        //    preventing cap bumps from appearing at the diameter line.
        this.topArc = new SWArc(
            new SWPoint(cx, cy), this.topRadius, 180, 0,
            this.arcThickness, this.topArcColor, 'butt'
        );
        this.topArc.setShowCenter(false);
        this.topArc.pixelRadiusOffset = this.arcThickness / 2;

        // ── Bottom arc: curved circumference of the lower half ──────────────────
        this.bottomArc = new SWArc(
            new SWPoint(cx, cy), this.bottomRadius, 180, 180,
            this.arcThickness, this.bottomArcColor, 'butt'
        );
        this.bottomArc.setShowCenter(false);
        this.bottomArc.pixelRadiusOffset = this.arcThickness / 2;

        this._syncSubObjects();
    }//end _buildSubObjects

    /**
     * Pushes the current center position and total rotation into all four
     * sub-objects.  Must be called before every draw.
     */
    _syncSubObjects() {
        const cx = this.center.x, cy = this.center.y;
        const r  = this.startAngle + this.rotation;   // effective rotation for sub-objects

        // Sync center coordinates
        this.topSector.vertex.x    = cx;  this.topSector.vertex.y    = cy;
        this.bottomSector.vertex.x = cx;  this.bottomSector.vertex.y = cy;
        this.topArc.center.x       = cx;  this.topArc.center.y       = cy;
        this.bottomArc.center.x    = cx;  this.bottomArc.center.y    = cy;

        // Sync rotation — sub-objects keep their fixed startAngle (0 / 180)
        this.topSector.rotation    = r;
        this.bottomSector.rotation = r;
        this.topArc.rotation       = r;
        this.bottomArc.rotation    = r;
    }//end _syncSubObjects

    // ─── Drawing ───────────────────────────────────────────────────────────────

    /**
     * Draws the disk in screen (pixel) coordinates.
     * Use drawOnGrid() for user-coordinate rendering.
     */
    draw() {
        this._syncSubObjects();
        this.topSector.draw();
        this.bottomSector.draw();
        this.topArc.draw();
        this.bottomArc.draw();
        if (this.shouldShowCenter && this.center && this.center.draw) {
            this.center.draw();
        }
    }//end draw

    /**
     * Draws the disk mapped through the given SWGrid.
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        this._syncSubObjects();
        this.topSector.drawOnGrid(grid);
        this.bottomSector.drawOnGrid(grid);
        this.topArc.drawOnGrid(grid);
        this.bottomArc.drawOnGrid(grid);
        if (this.shouldShowCenter && this.center && this.center.drawOnGrid) {
            this.center.drawOnGrid(grid);
        }
    }//end drawOnGrid

    // ─── Rotation animation ────────────────────────────────────────────────────

    /**
     * Increments the disk's rotation by deltaAngle degrees (CCW positive, CW negative).
     * Call each frame before draw() to spin the disk about its center.
     * @param {number} deltaAngle
     */
    rotate(deltaAngle) {
        this.rotation += deltaAngle;
    }//end rotate

    // ─── Breathing animation ───────────────────────────────────────────────────

    /**
     * Modulates BOTH radii to the same sinusoid value, so both halves
     * grow and shrink in unison.  Minimum clamped to 0.01.
     * @param {SWSinusoid} sinusoid
     * @param {number} t - elapsed time in seconds
     */
    breathe(sinusoid, t) {
        const r = Math.max(0.01, sinusoid.getValue(t));
        this.topRadius    = r;
        this.bottomRadius = r;
        this.topSector.setRadius(r);
        this.bottomSector.setRadius(r);
        this.topArc.setRadius(r);
        this.bottomArc.setRadius(r);
    }//end breathe

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

    /**
     * Sets the top-half radius and keeps the sub-objects in sync.
     * @param {number} r - user units
     */
    setTopRadius(r) {
        this.topRadius = r;
        if (this.topSector) this.topSector.setRadius(r);
        if (this.topArc)    this.topArc.setRadius(r);
    }//end setTopRadius

    /**
     * Sets the bottom-half radius and keeps the sub-objects in sync.
     * @param {number} r - user units
     */
    setBottomRadius(r) {
        this.bottomRadius = r;
        if (this.bottomSector) this.bottomSector.setRadius(r);
        if (this.bottomArc)    this.bottomArc.setRadius(r);
    }//end setBottomRadius

    /**
     * Sets the fill (sector) color for the top half only.
     * The arc outline is NOT changed — use setTopArcColor() separately.
     * @param {SWColor} swColor
     */
    setTopFillColor(swColor) {
        this.topFillColor = swColor ? SWColor.copy(swColor) : undefined;
        if (this.topSector) this.topSector.setFillColor(this.topFillColor);
    }//end setTopFillColor

    /**
     * Sets the fill (sector) color for the bottom half only.
     * @param {SWColor} swColor
     */
    setBottomFillColor(swColor) {
        this.bottomFillColor = swColor ? SWColor.copy(swColor) : undefined;
        if (this.bottomSector) this.bottomSector.setFillColor(this.bottomFillColor);
    }//end setBottomFillColor

    /**
     * Sets the arc outline color for the top half independently of the fill.
     * @param {SWColor} swColor
     */
    setTopArcColor(swColor) {
        this.topArcColor = swColor ? SWColor.copy(swColor) : undefined;
        if (this.topArc) this.topArc.setStrokeColor(this.topArcColor);
    }//end setTopArcColor

    /**
     * Sets the arc outline color for the bottom half independently of the fill.
     * @param {SWColor} swColor
     */
    setBottomArcColor(swColor) {
        this.bottomArcColor = swColor ? SWColor.copy(swColor) : undefined;
        if (this.bottomArc) this.bottomArc.setStrokeColor(this.bottomArcColor);
    }//end setBottomArcColor

    /**
     * Sets the alpha for the top half's fill color.  Clamped to [0, 100].
     * Rebuilds the p5 color object and propagates to the sector only.
     * @param {number} alpha
     */
    setTopAlpha(alpha) {
        if (!this.topFillColor) return;
        this.topFillColor.a = Math.max(0, Math.min(100, alpha));
        this.topFillColor.col = color(
            this.topFillColor.h, this.topFillColor.s,
            this.topFillColor.b, this.topFillColor.a
        );
        if (this.topSector) this.topSector.setFillColor(this.topFillColor);
    }//end setTopAlpha

    /**
     * Sets the alpha for the bottom half's fill color.  Clamped to [0, 100].
     * @param {number} alpha
     */
    setBottomAlpha(alpha) {
        if (!this.bottomFillColor) return;
        this.bottomFillColor.a = Math.max(0, Math.min(100, alpha));
        this.bottomFillColor.col = color(
            this.bottomFillColor.h, this.bottomFillColor.s,
            this.bottomFillColor.b, this.bottomFillColor.a
        );
        if (this.bottomSector) this.bottomSector.setFillColor(this.bottomFillColor);
    }//end setBottomAlpha

    /**
     * Sets the alpha for the top arc's outline color.  Clamped to [0, 100].
     * @param {number} alpha
     */
    setTopArcAlpha(alpha) {
        if (!this.topArcColor) return;
        this.topArcColor.a = Math.max(0, Math.min(100, alpha));
        this.topArcColor.col = color(
            this.topArcColor.h, this.topArcColor.s,
            this.topArcColor.b, this.topArcColor.a
        );
        if (this.topArc) this.topArc.setStrokeColor(this.topArcColor);
    }//end setTopArcAlpha

    /**
     * Sets the alpha for the bottom arc's outline color.  Clamped to [0, 100].
     * @param {number} alpha
     */
    setBottomArcAlpha(alpha) {
        if (!this.bottomArcColor) return;
        this.bottomArcColor.a = Math.max(0, Math.min(100, alpha));
        this.bottomArcColor.col = color(
            this.bottomArcColor.h, this.bottomArcColor.s,
            this.bottomArcColor.b, this.bottomArcColor.a
        );
        if (this.bottomArc) this.bottomArc.setStrokeColor(this.bottomArcColor);
    }//end setBottomArcAlpha

    /**
     * Sets the stroke weight (pixels) for both circumference arcs.
     * Also updates pixelRadiusOffset so arcs remain flush outside the sectors.
     * @param {number} w
     */
    setArcThickness(w) {
        this.arcThickness = w;
        if (this.topArc) {
            this.topArc.setStrokeWeight(w);
            this.topArc.pixelRadiusOffset    = w / 2;
        }
        if (this.bottomArc) {
            this.bottomArc.setStrokeWeight(w);
            this.bottomArc.pixelRadiusOffset = w / 2;
        }
    }//end setArcThickness

    /**
     * Sets the static orientation of the shared diameter (CCW from +x axis).
     * Does not affect the accumulated rotation from rotate().
     * @param {number} degrees
     */
    setStartAngle(degrees) {
        this.startAngle = degrees;
    }//end setStartAngle

    /**
     * Toggles the center-point marker.
     * @param {boolean} [show=true]
     */
    setShowCenter(show = true) {
        this.shouldShowCenter = show;
    }//end setShowCenter

    // ─── Reset ─────────────────────────────────────────────────────────────────

    /**
     * Resets all animated / slider-driven values to the originals stored at
     * construction time.  Does NOT move the center position.
     */
    reset() {
        this.rotation   = 0;
        this.startAngle = this.originalStartAngle;

        this.setTopRadius(this.originalTopRadius);
        this.setBottomRadius(this.originalBottomRadius);

        this.arcThickness = this.originalArcThickness;
        if (this.topArc)    this.topArc.setStrokeWeight(this.originalArcThickness);
        if (this.bottomArc) this.bottomArc.setStrokeWeight(this.originalArcThickness);

        if (this.originalTopFillColor) {
            this.topFillColor = SWColor.copy(this.originalTopFillColor);
            this.setTopFillColor(this.topFillColor);
        }
        if (this.originalBottomFillColor) {
            this.bottomFillColor = SWColor.copy(this.originalBottomFillColor);
            this.setBottomFillColor(this.bottomFillColor);
        }
        if (this.originalTopArcColor) {
            this.topArcColor = SWColor.copy(this.originalTopArcColor);
            this.setTopArcColor(this.topArcColor);
        }
        if (this.originalBottomArcColor) {
            this.bottomArcColor = SWColor.copy(this.originalBottomArcColor);
            this.setBottomArcColor(this.bottomArcColor);
        }
    }//end reset

    /**
     * Resets only the top half's fill color to the original.
     */
    resetTopColor() {
        if (this.originalTopFillColor) {
            this.topFillColor = SWColor.copy(this.originalTopFillColor);
            this.setTopFillColor(this.topFillColor);
        }
    }//end resetTopColor

    /**
     * Resets only the bottom half's fill color to the original.
     */
    resetBottomColor() {
        if (this.originalBottomFillColor) {
            this.bottomFillColor = SWColor.copy(this.originalBottomFillColor);
            this.setBottomFillColor(this.bottomFillColor);
        }
    }//end resetBottomColor

    // ─── Copy ──────────────────────────────────────────────────────────────────

    /**
     * Returns a deep copy of the given SWTwoTonedDisk.
     * @param {SWTwoTonedDisk} other
     * @returns {SWTwoTonedDisk}
     */
    static copy(other) {
        if (!(other instanceof SWTwoTonedDisk)) {
            throw new Error('Argument to SWTwoTonedDisk.copy must be an SWTwoTonedDisk instance');
        }
        const d = new SWTwoTonedDisk(
            SWPoint.copy(other.center),
            other.originalTopRadius,
            other.originalStartAngle,
            other.topFillColor,
            other.bottomFillColor,
            other.originalArcThickness
        );
        d.topRadius    = other.topRadius;
        d.bottomRadius = other.bottomRadius;
        d.startAngle   = other.startAngle;
        d.rotation     = other.rotation;
        d.setTopRadius(d.topRadius);
        d.setBottomRadius(d.bottomRadius);
        d.shouldShowCenter = other.shouldShowCenter;
        return d;
    }//end copy

    // ─── toString ──────────────────────────────────────────────────────────────

    toString() {
        return `SWTwoTonedDisk(center: ${this.center.toString()}, ` +
               `topRadius: ${this.topRadius.toFixed(2)}, bottomRadius: ${this.bottomRadius.toFixed(2)}, ` +
               `startAngle: ${this.startAngle}°, rotation: ${this.rotation.toFixed(1)}°, ` +
               `arcThickness: ${this.arcThickness})`;
    }//end toString

}//end SWTwoTonedDisk class