SWEyeball Saga

Class Reference — SWEyeball, SWPoint, SWColor & SWDisk

What Is SWEyeball?

SWEyeball is a self-contained class that draws a complete, animated eyeball using the p5.js canvas library. It encapsulates four separate disk objects and all of the geometry needed to make the pupil move, track a target, and stay constrained inside the white of the eye — so the sketch that uses the class only needs a few lines of setup code.

Rather than building the eye directly in a sketch (managing eight separate disk and point variables, writing custom drag math, and recomputing glint positions every frame), you create one SWEyeball and call a handful of friendly methods. The class is the result of ten stages of motivated development recorded in the SWEyeball Saga.

How the Layers Fit Together

Each eyeball is composed of four layers drawn back-to-front every frame:

┌─────────────────────────────────────────────────────────┐ │ SWEyeball │ │ │ │ Layer 1 (bottom) eyeBase SWDisk ← sclera │ │ Layer 2 pupil SWDisk ← iris + pupil │ │ Layer 3 topGlint SWDisk ← main glint │ │ Layer 4 (top) bottomGlint SWDisk ← fill glint │ │ │ │ Each SWDisk owns a center SWPoint (position anchor) │ │ Each SWDisk uses SWColor for fill and stroke colors │ └─────────────────────────────────────────────────────────┘ Dependency chain: SWEyeball → SWDisk → SWPoint (position) → SWColor (fill color) → SWColor (stroke color) → SWGrid (coordinate conversion)

Quick-Start: Two Eyes in a Sketch

The minimum code to get two draggable, mouse-following eyes onto a p5.js canvas:

// In setup() — after colorMode() and initializeSWColors(): let leftEye = new SWEyeball( new SWPoint(-2.5, 6), // center position (grid user-units) 2, // sclera radius 2, // sclera border thickness [swWhite, swBlack], // [sclera fill, sclera border] 0.4, // pupil factor (40% of sclera radius) 8, // iris ring thickness [swBlack, swBlue] // [pupil fill, iris ring color] ); let rightEye = new SWEyeball( new SWPoint(2.5, 6), 2, 2, [swWhite, swBlack], 0.4, 8, [swBlack, swBlue] ); // In draw() — that's it for drawing: leftEye.drawOnGrid(grid); rightEye.drawOnGrid(grid); // In mousePressed(): if (leftEye.handleMousePressed(mouseX, mouseY, grid)) return; if (rightEye.handleMousePressed(mouseX, mouseY, grid)) return; // In mouseDragged(): leftEye.handleMouseDragged(mouseX, mouseY, grid); rightEye.handleMouseDragged(mouseX, mouseY, grid); // In mouseReleased(): leftEye.handleMouseReleased(); rightEye.handleMouseReleased();

The Polar Coordinate System

The pupil position is stored internally as a polar offset from the eye center — a distance (radius) and a direction (angleDeg). All methods that move the pupil use this system:

90° (up / north) | 135° ------+------ 45° (upper-left | upper-right) | 180° (left) ----+---- 0° / 360° (right / east) | 225° ------+------ 315° (lower-left | lower-right) | 270° (down / south) offsetRadius = 0 → pupil dead-center offsetRadius = _maxPupilOffset → pupil edge touching sclera edge
Newcomer tip: Think of the angle like the hours on a clock face — except 0° is 3 o'clock (right), 90° is 12 o'clock (up), 180° is 9 o'clock (left), and 270° is 6 o'clock (down). The angular direction is counter-clockwise, which is the standard math convention.

Constructor Signature

new SWEyeball( eyeCenter, // SWPoint — position of the eyeball center in grid user-units radius, // number — sclera (white) radius in user-units baseThickness, // number — stroke weight for the sclera border eyeColors, // SWColor[2] — [scleraFillColor, scleraBorderColor] pupilFactor, // number — pupil radius = radius × pupilFactor (e.g. 0.4) irisThickness, // number — stroke weight for the iris ring around the pupil pupilColors // SWColor[2] — [pupilFillColor, irisStrokeColor] )
Important: Construct inside p5.js setup()after calling colorMode() and initializeSWColors(). The glint geometry uses p5.js cos() and radians(), which are only available after p5 is running.

Public Properties You Can Read

PropertyTypeDescription
eyeCenterSWPointCenter of the entire eyeball. Changing this moves the whole eye.
radiusnumberSclera radius in user-units.
pupilFactornumberFraction of radius used for the pupil disk (e.g. 0.4 → 40%).
pupilOffsetRadiusnumberCurrent distance of pupil center from eyeCenter. Updated live by drag and setPupilOffset().
pupilOffsetAngleDegnumberCurrent gaze direction in degrees [0, 360). Updated live.
scleraColorSWColorFill color of the white sclera.
borderColorSWColorStroke color of the sclera border.
pupilColorSWColorFill color of the dark pupil center.
irisColorSWColorStroke (ring) color around the pupil — the colored iris. Change live with setIrisColor().
isPupilDraggablebooleanWhether the pupil can be dragged. Default: true. Set before rebuild().

Glint Customization Properties

Glints are the small white highlights that make the eye look shiny and alive. Override these before calling rebuild() to change their appearance:

PropertyDefaultDescription
glintFactor0.4Top glint radius = pupilRadius × glintFactor.
glintColorswWhiteColor applied to both glint disks.
glintAngleDeg135°Direction of the primary (top-left) glint from the pupil center. 135° = upper-left.
glintBottomAngleDeg315°Direction of the secondary (bottom-right) glint. 315° = lower-right.
glintDistanceFactor1.0Top glint distance = pupilRadius × factor.
glintBottomDistFactor0.7Bottom glint distance = pupilRadius × factor.

Private / Internal References

These are set by _buildComponents() and are available for reading (but should not be overwritten directly):

PropertyTypeDescription
eyeBaseSWDiskThe white sclera disk.
pupilSWDiskThe iris/pupil disk. Its .center (SWPoint) is what moves.
topGlintSWDiskPrimary top-left highlight disk.
bottomGlintSWDiskSecondary bottom-right highlight disk.
_maxPupilOffsetnumberMaximum pupil travel distance = radius − pupilRadius. Read-only.
_pupilRadiusnumberCached pupil disk radius in user-units.

Drawing

drawOnGrid(grid)Call every frame from draw().

Renders all four internal disks in the correct z-order (sclera → pupil → glints). The grid parameter is the SWGrid instance that handles the user-unit → pixel coordinate conversion.

// In p5.js draw(): leftEye.drawOnGrid(grid); rightEye.drawOnGrid(grid);

draw() — Bypasses the grid and draws in raw screen pixels. Rarely needed; prefer drawOnGrid().

Programmatic Pupil Positioning

setPupilOffset(offsetRadius, angleDeg)

Moves the pupil to a polar position relative to the eye center. offsetRadius is automatically clamped to [0, _maxPupilOffset] so you can safely pass large values and the pupil stops at the boundary. angleDeg is normalized to [0, 360).

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 to max // Read it back: const { radius, angleDeg } = leftEye.pupilOffset; console.log(`Pupil at r=${radius.toFixed(2)}, θ=${angleDeg.toFixed(0)}°`);

lookAt(targetUserX, targetUserY)

Points the pupil directly toward a target position (grid user-units), always using maximum offset so the gaze direction is fully visible. This is designed for high-frequency use inside draw() — it performs no console logging and is as fast as possible.

// In draw() — convert mouse to user-units first: const { x: mouseUserX, y: mouseUserY } = grid.screenToUser(mouseX, mouseY); leftEye.lookAt(mouseUserX, mouseUserY); rightEye.lookAt(mouseUserX, mouseUserY);
Emergent vergence: When two eyes at different positions both call lookAt() with the same target, each computes the angle from its own center — so each eye points at a slightly different angle. This produces a natural "turning inward" (vergence) effect that was never explicitly programmed. It just falls out of the geometry.

lookAtAngle(angleDeg)

Companion to lookAt() for Track-ON mode, when a single shared angle has already been computed externally and both eyes should point in exactly the same direction.

// Track-ON mode: compute one shared angle from the midpoint between the eyes const mid = { x: (leftEye.eyeCenter.x + rightEye.eyeCenter.x) / 2, y: (leftEye.eyeCenter.y + rightEye.eyeCenter.y) / 2 }; const dx = mouseUserX - mid.x; const dy = mouseUserY - mid.y; const sharedAngle = (degrees(Math.atan2(dy, dx)) + 360) % 360; leftEye.lookAtAngle(sharedAngle); rightEye.lookAtAngle(sharedAngle);

Mouse Interaction

handleMousePressed(mx, my, grid, tolerance=10)boolean

Call from p5.js mousePressed(). Returns true if the user clicked on this eye's pupil (within tolerance screen pixels). That true signals that the click was "consumed" — other click handlers (like grid-toggle) should be skipped.

handleMouseDragged(mx, my, grid)boolean

Call from p5.js mouseDragged() every frame while the button is held. Internally it converts screen → user coordinates, clamps the pupil inside the sclera boundary, moves the pupil and both glints, and syncs pupilOffsetRadius / pupilOffsetAngleDeg. Returns true while this eye is the one being dragged.

The constraint math inside handleMouseDragged():

// Step 1 — vector from eye center to raw mouse position const dx = rawX - eyeCenter.x; const dy = rawY - eyeCenter.y; // Step 2 — how far does the user want to drag? const dist = Math.sqrt(dx*dx + dy*dy); // Step 3 — clamp if beyond the allowed maximum if (dist > _maxPupilOffset) { const scale = _maxPupilOffset / dist; // shrink to the legal length userX = eyeCenter.x + dx * scale; // same direction, shorter magnitude userY = eyeCenter.y + dy * scale; } else { userX = rawX; userY = rawY; // inside — no correction needed }

handleMouseReleased()

Call from p5.js mouseReleased(). Clears the drag state.

Getters (Read-Only Properties)

isDraggingboolean

True while the user is actively dragging this eye's pupil. Use to prevent other code (like mouse follow) from overriding an in-progress drag.

if (!leftEye.isDragging) { leftEye.lookAt(mouseUserX, mouseUserY); }

pupilOffset{ radius, angleDeg }

Returns the current polar position as a convenience object. Stays in sync with both setPupilOffset() and interactive drag.

Live Color Change

setIrisColor(swColor)

Changes the iris ring color immediately, without requiring a rebuild(). Also updates the stored irisColor so any future rebuild picks up the new value.

// Typical use — wired to a color picker 'input' event: const newColor = SWColor.fromHex(colorPicker.value, 'irisColor'); leftEye.setIrisColor(newColor); rightEye.setIrisColor(newColor);

setBaseAlpha(alpha)

Sets the sclera fill transparency (0 = invisible, 100 = fully opaque). Handy during development to see what's underneath.

Rebuilding After Glint Changes

rebuild()

Re-runs _buildComponents() after you change glint properties like glintFactor, glintAngleDeg, or glintColor. The existing pupil offset is preserved through the rebuild.

leftEye.glintFactor = 0.6; // larger glints leftEye.glintAngleDeg = 120; // shift the highlight angle leftEye.glintColor = swGold; leftEye.rebuild(); // reconstruct internal disks

Utility

toString()string

console.log(leftEye.toString()); // → "SWEyeball(center: SWPoint(x:-2.5, y:6), radius: 2, // pupilFactor: 0.4, pupilOffset: r=1.200 θ=45.0°, dragging: false)"

SWEyeball is built on three smaller, reusable classes. Each one handles exactly one responsibility — a design principle called separation of concerns.

SWPoint — A Position in Space

SWPoint stores an (x, y) coordinate in grid user-units along with optional styling (stroke weight, color, label). It is the fundamental anchor for everything else: every disk, line, and triangle in the SketchWave system is positioned by giving it an SWPoint as its center or endpoint.

Key features:

  • setPosition(x, y) — move the point to new coordinates.
  • containsPoint(mx, my, grid, tolerance) — hit-test: is the mouse within tolerance pixels of this point on screen? Used for drag detection.
  • setDraggable(bool) — marks the point as interactive.
  • SWPoint.copy(other) — static deep-copy constructor. Creates an independent duplicate, including the pen trail and label offset.
  • Pen trail — optional: call point.penOn = true to record a history of positions, then draw the trail for motion effects.
  • drawOnGrid(grid) — draws the point as a visible dot on the grid. Useful during debugging.
// Create a point at (x=3, y=6) with a blue color label "B" const pt = new SWPoint(3, 6, undefined, 8, swBlue, "B"); pt.showLabel = true; pt.drawOnGrid(grid); // Move it: pt.setPosition(4, 5); // Deep copy — the copy moves independently of the original: const ptCopy = SWPoint.copy(pt);
Why use SWPoint instead of just (x, y)?
A plain pair of numbers can't be labeled, colored, hit-tested, or subscribed to by other objects. SWPoint is a named, styled, interactive object — which is why SWDisk takes one as its center rather than raw coordinates.

SWColor — A Color in HSB Space

SWColor stores a color as four numbers: hue (0–360), saturation (0–100), brightness (0–100), and alpha transparency (0–100). It works with p5.js's colorMode(HSB, 360, 100, 100, 100).

Key features:

  • new SWColor(h, s, b, a, name) — constructor. Hue is automatically wrapped to [0, 360).
  • SWColor.copy(other) — static deep copy. All SW classes store copies, so an external change to a color doesn't silently ripple through.
  • SWColor.fromHex(hexStr, name) — static factory that converts a CSS hex string (like "#3a7fd5") to HSB. Used to bridge HTML color pickers to the SketchWave system.
  • darken(factor), brighten(factor), saturate(factor), desaturate(factor), withAlpha(a) — return a new adjusted color without modifying the original.
  • initializeSWColors() — call once in setup() to initialize all global presets: swRed, swBlue, swGreen, swWhite, swBlack, and 30+ others.
// Predefined colors — initialized in setup() via initializeSWColors(): swBlack = new SWColor( 0, 0, 0, 100, "black"); swWhite = new SWColor( 0, 0, 100, 100, "white"); swBlue = new SWColor(240, 100, 100, 100, "blue"); swRed = new SWColor( 0, 100, 100, 100, "red"); swGold = new SWColor( 45, 94, 92, 100, "gold"); // ...30+ more // Adjust without modifying original: const paleBlue = swBlue.withAlpha(40); // Convert from hex (e.g. from HTML color picker): const irisColor = SWColor.fromHex("#5c90c8", "iris");
Why HSB instead of RGB?
HSB maps directly to how artists think: hue = "which color?", saturation = "how vivid?", brightness = "how dark?". Darkening a color means subtracting from brightness — one number, predictable result. In RGB you'd have to adjust all three channels and guess.

SWDisk — A Styled Circle

SWDisk draws a filled, stroked disk (circle) at an SWPoint center position, in either screen pixel coordinates or grid user-units. It is the fundamental drawn primitive inside SWEyeball.

Key features:

  • new SWDisk(center, radius, thickness, fillColor, strokeColor) — all colors are copied on construction (no shared-reference surprises).
  • drawOnGrid(grid) — call every frame. Converts center from user-units to pixels, then draws the disk.
  • shouldShowCenter — boolean; show the center SWPoint as a visible dot (useful for debugging). Default: true. Set to false when used inside SWEyeball.
  • setFillAlpha(alpha), setStrokeAlpha(alpha) — change transparency after construction without a full rebuild.
  • breathe(sinusoid, t) — modulate radius with an SWSinusoid wave over time for animated pulse effects.
  • area, circumference — computed at construction.
// Standalone use: const sclera = new SWDisk( new SWPoint(0, 0), // center at origin 2, // radius = 2 user-units 2, // border thickness swWhite, // fill color swBlack // border color ); sclera.shouldShowCenter = false; // In draw(): sclera.drawOnGrid(grid); // Transparent fill (reveal what's underneath for debugging): sclera.setFillAlpha(30);
Inside SWEyeball: Each of the four layers (sclera, pupil, top glint, bottom glint) is an SWDisk. When you call leftEye.drawOnGrid(grid), it simply calls drawOnGrid(grid) on each of its four disks in order.

Files are loaded directly from the project folder. Use the Copy button to copy any file's full text to the clipboard.

Loading swEyeball.js…
Loading swPoint.js…
Loading swColor.js…
Loading swDisk.js…