function initParticles(count) {
particles = [];
for (let i = 0; i < count; i++) {
particles.push(new Particle(random(width), random(height)));
}
}
๐ง Subcomponents:
for-loop
Create Particles at Random Positions
for (let i = 0; i < count; i++) { particles.push(new Particle(random(width), random(height))); }
Creates the specified number of particles at random x,y positions across the canvas and adds them to the particles array
Line by Line:
particles = []
- Clears the particles array, removing any existing particles before creating new ones
for (let i = 0; i < count; i++)
- Loops from 0 to count-1, creating one particle per iteration
particles.push(new Particle(random(width), random(height)))
- Creates a new Particle object at a random position on the canvas and adds it to the particles array
function handleCollisions() {
const n = particles.length;
if (n > maxParticles) return;
const collisionRadiusSq = 9;
for (let i = 0; i < n; i++) {
const p1 = particles[i];
for (let j = i + 1; j < n; j++) {
const p2 = particles[j];
const dx = p1.pos.x - p2.pos.x;
const dy = p1.pos.y - p2.pos.y;
const distSq = dx * dx + dy * dy;
if (distSq < collisionRadiusSq) {
const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag();
if (relSpeed > 1.5 && particles.length < maxParticles) {
const midX = (p1.pos.x + p2.pos.x) * 0.5;
const midY = (p1.pos.y + p2.pos.y) * 0.5;
const newborn = new Particle(midX, midY);
newborn.vel = p5.Vector.add(p1.vel, p2.vel)
.mult(0.5)
.rotate(random(-0.5, 0.5));
newborn.size = (p1.size + p2.size) * 0.45;
particles.push(newborn);
}
const normal = createVector(dx, dy).normalize().mult(0.05);
p1.vel.add(normal);
p2.vel.sub(normal);
}
}
}
}
๐ง Subcomponents:
conditional
Prevent Exceeding Max Particles
if (n > maxParticles) return
Stops the collision detection early if particle count already exceeds the maximum, preventing performance issues
for-loop
Nested Loop Collision Detection
for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { ... } }
Compares each particle with every other particle that comes after it, checking if they're close enough to collide
calculation
Distance Squared Calculation
const distSq = dx * dx + dy * dy
conditional
Spawn New Particle on Collision
if (relSpeed > 1.5 && particles.length < maxParticles) { ... particles.push(newborn); }
If two particles collide with sufficient relative speed and we haven't hit the max, creates a new particle at the collision point
calculation
Separating Impulse
const normal = createVector(dx, dy).normalize().mult(0.05); p1.vel.add(normal); p2.vel.sub(normal)
Pushes colliding particles apart slightly so they don't stick together
Line by Line:
const n = particles.length
- Stores the current number of particles to avoid recalculating it in the loop condition
if (n > maxParticles) return
- If we've already exceeded the maximum particle count (900), exit early to avoid expensive collision checks
const collisionRadiusSq = 9
- Sets the collision threshold to 9 pixels squared (3 pixel radius). Using squared distance avoids expensive sqrt() calls
for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { ... } }
- Nested loops that check each pair of particles exactly once. Starting j at i+1 avoids checking the same pair twice
const dx = p1.pos.x - p2.pos.x; const dy = p1.pos.y - p2.pos.y
- Calculates the x and y distance between two particles
const distSq = dx * dx + dy * dy
- Calculates squared distance (faster than using sqrt) to compare against the collision radius
if (distSq < collisionRadiusSq)
- Checks if particles are close enough to collide (within 3 pixel radius)
const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag()
- Calculates the relative speed between the two particles to determine collision impact
if (relSpeed > 1.5 && particles.length < maxParticles)
- Only spawns a new particle if the collision is fast enough (speed > 1.5) and we haven't hit the max particle limit
const midX = (p1.pos.x + p2.pos.x) * 0.5; const midY = (p1.pos.y + p2.pos.y) * 0.5
- Calculates the midpoint between the two colliding particles where the new particle will spawn
newborn.vel = p5.Vector.add(p1.vel, p2.vel).mult(0.5).rotate(random(-0.5, 0.5))
- Sets the new particle's velocity to the average of the two colliding particles' velocities, then rotates it slightly for variation
newborn.size = (p1.size + p2.size) * 0.45
- Makes the new particle slightly smaller than the average of its parent particles
const normal = createVector(dx, dy).normalize().mult(0.05); p1.vel.add(normal); p2.vel.sub(normal)
- Creates a separation impulse: pushes particle 1 away from particle 2 and vice versa so they don't stick
function buildCrystalGeometry() {
crystalLines = [];
if (particles.length < 2) return;
const k = 2;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
let nearest = [];
for (let j = 0; j < particles.length; j++) {
if (i === j) continue;
const q = particles[j];
const dx = p.pos.x - q.pos.x;
const dy = p.pos.y - q.pos.y;
const d = dx * dx + dy * dy;
nearest.push({ j, d });
}
nearest.sort((a, b) => a.d - b.d);
const limit = min(k, nearest.length);
for (let n = 0; n < limit; n++) {
const neighborIndex = nearest[n].j;
const q = particles[neighborIndex];
if (i < neighborIndex) {
crystalLines.push({
x1: p.pos.x,
y1: p.pos.y,
x2: q.pos.x,
y2: q.pos.y
});
}
}
}
}
๐ง Subcomponents:
for-loop
Find Nearest Neighbors for Each Particle
for (let i = 0; i < particles.length; i++) { ... for (let j = 0; j < particles.length; j++) { ... nearest.push({ j, d }); } }
For each particle, calculates distances to all other particles and stores them in the nearest array
calculation
Sort Neighbors by Distance
nearest.sort((a, b) => a.d - b.d)
Sorts the nearest array so the closest particles come first
for-loop
Connect to K Nearest Neighbors
for (let n = 0; n < limit; n++) { ... if (i < neighborIndex) { crystalLines.push(...); } }
Connects each particle to its 2 nearest neighbors, avoiding duplicate connections by only storing pairs where i < j
Line by Line:
crystalLines = []
- Clears any existing crystal lines before building new geometry
if (particles.length < 2) return
- Exits early if there aren't enough particles to create connections
const k = 2
- Sets k to 2, meaning each particle will connect to its 2 nearest neighbors
for (let i = 0; i < particles.length; i++) { const p = particles[i]; let nearest = []
- Loops through each particle and creates an empty array to store its nearest neighbors
for (let j = 0; j < particles.length; j++) { if (i === j) continue; ... nearest.push({ j, d }); }
- For each other particle (skipping itself), calculates the squared distance and stores the particle index and distance
nearest.sort((a, b) => a.d - b.d)
- Sorts the nearest array by distance, so the closest particles come first
const limit = min(k, nearest.length)
- Sets the connection limit to the minimum of k (2) and the actual number of neighbors available
for (let n = 0; n < limit; n++) { const neighborIndex = nearest[n].j; const q = particles[neighborIndex]
- Loops through the k nearest neighbors and gets the actual particle object
if (i < neighborIndex) { crystalLines.push({ x1: p.pos.x, y1: p.pos.y, x2: q.pos.x, y2: q.pos.y }); }
- Only stores the connection if i < neighborIndex to avoid storing the same connection twice (once from each particle's perspective)
function drawCrystals() {
background(0, 0, 0, 10);
const t = frameCount * 0.02;
const baseHue = (200 + 40 * sin(t)) % 360;
strokeWeight(1.4);
noFill();
for (let lineSeg of crystalLines) {
const hue = (baseHue + random(-6, 6)) % 360;
stroke(hue, 80, 100, 80);
line(lineSeg.x1, lineSeg.y1, lineSeg.x2, lineSeg.y2);
const mx = (lineSeg.x1 + lineSeg.x2) * 0.5;
const my = (lineSeg.y1 + lineSeg.y2) * 0.5;
noStroke();
fill(hue, 90, 100, 60);
ellipse(mx, my, 3, 3);
}
}
๐ง Subcomponents:
calculation
Fading Background for Crystal Mode
background(0, 0, 0, 10)
Clears canvas with very transparent black, creating a slow fade effect for crystal lines
calculation
Animated Hue Based on Frame Count
const t = frameCount * 0.02; const baseHue = (200 + 40 * sin(t)) % 360
Creates a slowly oscillating hue that cycles between cyan and blue-magenta, making the crystals shimmer
for-loop
Draw Each Crystal Line with Nodes
for (let lineSeg of crystalLines) { ... stroke(hue, 80, 100, 80); line(...); ... fill(...); ellipse(...); }
Draws each connection line and places a small glowing node at the midpoint of each line
Line by Line:
background(0, 0, 0, 10)
- Fills with very transparent black (alpha 10), creating a slow fade effect where old lines gradually disappear
const t = frameCount * 0.02
- Converts frameCount to a time value that increases slowly (0.02 multiplier makes it move slowly)
const baseHue = (200 + 40 * sin(t)) % 360
- Creates an oscillating hue value using sine wave. It oscillates between 160 and 240 (cyan to blue-magenta), creating a shimmering effect
strokeWeight(1.4)
- Sets the line thickness to 1.4 pixels for a delicate appearance
noFill()
- Disables fill so only the line outline is drawn
for (let lineSeg of crystalLines)
- Loops through each stored crystal line segment
const hue = (baseHue + random(-6, 6)) % 360
- Adds slight random variation to the hue for each line, creating subtle color flicker
stroke(hue, 80, 100, 80); line(lineSeg.x1, lineSeg.y1, lineSeg.x2, lineSeg.y2)
- Sets the stroke color and draws a line from the first point to the second point
const mx = (lineSeg.x1 + lineSeg.x2) * 0.5; const my = (lineSeg.y1 + lineSeg.y2) * 0.5
- Calculates the midpoint of the line segment
noStroke(); fill(hue, 90, 100, 60); ellipse(mx, my, 3, 3)
- Draws a small 3-pixel glowing dot at the midpoint of each line, creating crystalline nodes
function analyzeFlow() {
const cx = width * 0.5;
const cy = height * 0.5;
let bins = [];
for (let i = 0; i < NUM_BINS; i++) {
bins.push({
count: 0,
distSum: 0,
speedSum: 0
});
}
for (let p of particles) {
const dx = p.pos.x - cx;
const dy = p.pos.y - cy;
let angle = atan2(dy, dx);
if (angle < 0) angle += TWO_PI;
let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS));
if (idx >= NUM_BINS) idx = NUM_BINS - 1;
const dist = sqrt(dx * dx + dy * dy);
const speed = p.vel.mag();
const bin = bins[idx];
bin.count++;
bin.distSum += dist;
bin.speedSum += speed;
}
const baseRadius = min(width, height) * 0.3;
for (let bin of bins) {
if (bin.count > 0) {
bin.avgDist = bin.distSum / bin.count;
bin.avgSpeed = bin.speedSum / bin.count;
} else {
bin.avgDist = baseRadius;
bin.avgSpeed = 0;
}
}
return bins;
}
๐ง Subcomponents:
for-loop
Initialize Empty Bins
for (let i = 0; i < NUM_BINS; i++) { bins.push({ count: 0, distSum: 0, speedSum: 0 }); }
Creates 8 empty bins to categorize particles by their angular direction from the center
for-loop
Categorize Particles into Angular Bins
for (let p of particles) { ... let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS)); ... bin.count++; bin.distSum += dist; bin.speedSum += speed; }
For each particle, calculates its angle from center, assigns it to a bin, and accumulates statistics
for-loop
Calculate Average Distance and Speed per Bin
for (let bin of bins) { if (bin.count > 0) { bin.avgDist = bin.distSum / bin.count; bin.avgSpeed = bin.speedSum / bin.count; } else { ... } }
Converts accumulated sums into averages for each bin, providing statistics about particle flow in each direction
Line by Line:
const cx = width * 0.5; const cy = height * 0.5
- Calculates the center of the canvas, which is the reference point for angular analysis
let bins = []; for (let i = 0; i < NUM_BINS; i++) { bins.push({ count: 0, distSum: 0, speedSum: 0 }); }
- Creates 8 empty bins (objects) to collect statistics about particles in each angular direction
for (let p of particles)
- Loops through each particle to analyze its position and velocity
const dx = p.pos.x - cx; const dy = p.pos.y - cy; let angle = atan2(dy, dx)
- Calculates the angle from the canvas center to the particle using atan2, which returns values from -ฯ to ฯ
if (angle < 0) angle += TWO_PI
- Converts negative angles to positive by adding 2ฯ, so all angles are in the range 0 to 2ฯ
let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS)); if (idx >= NUM_BINS) idx = NUM_BINS - 1
- Maps the angle to a bin index (0-7), clamping to prevent out-of-bounds access
const dist = sqrt(dx * dx + dy * dy); const speed = p.vel.mag()
- Calculates the distance from center and the particle's current speed
bin.count++; bin.distSum += dist; bin.speedSum += speed
- Accumulates statistics for this bin: particle count, total distance, and total speed
if (bin.count > 0) { bin.avgDist = bin.distSum / bin.count; bin.avgSpeed = bin.speedSum / bin.count; } else { bin.avgDist = baseRadius; bin.avgSpeed = 0; }
- Calculates average distance and speed per bin. If a bin has no particles, uses default values
return bins
- Returns the array of bins containing statistics about particle flow in each direction
function generateSymmetrySuggestions() {
if (particles.length === 0) return;
const bins = analyzeFlow();
let indices = [...Array(NUM_BINS).keys()];
indices.sort((a, b) => bins[a].count - bins[b].count);
const cx = width * 0.5;
const cy = height * 0.5;
const numSuggestions = 4;
suggestions = [];
for (let i = 0; i < numSuggestions; i++) {
const idx = indices[i % indices.length];
const bin = bins[idx];
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
let radius = bin.avgDist;
const minR = min(width, height) * 0.18;
const maxR = min(width, height) * 0.4;
if (!isFinite(radius) || radius < minR) radius = minR;
if (radius > maxR) radius = maxR;
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
suggestions.push({
x: pos.x,
y: pos.y,
radius: radius * 0.9,
isRepulsor: false,
type: 'symmetry'
});
}
aiMode = 'symmetry';
}
๐ง Subcomponents:
calculation
Analyze Current Particle Flow
const bins = analyzeFlow()
Calls analyzeFlow() to get statistics about particle distribution in each angular sector
calculation
Sort Bins by Particle Density
indices.sort((a, b) => bins[a].count - bins[b].count)
Sorts the bin indices so the least populated sectors come first, allowing us to add wells to balance the system
for-loop
Generate Symmetry Well Suggestions
for (let i = 0; i < numSuggestions; i++) { ... suggestions.push({ ... }); }
Creates 4 attractor wells positioned in the least populated sectors to create visual balance
Line by Line:
if (particles.length === 0) return
- Exits early if there are no particles to analyze
const bins = analyzeFlow()
- Calls analyzeFlow() to get statistics about particles in each angular direction
let indices = [...Array(NUM_BINS).keys()]
- Creates an array [0, 1, 2, 3, 4, 5, 6, 7] representing all 8 bins
indices.sort((a, b) => bins[a].count - bins[b].count)
- Sorts indices so bins with fewer particles come first, identifying the least populated sectors
const numSuggestions = 4
- Sets the number of suggested wells to 4
for (let i = 0; i < numSuggestions; i++)
- Loops 4 times to create 4 suggestions
const idx = indices[i % indices.length]
- Gets the i-th least populated bin index (using modulo to wrap around if needed)
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS)
- Calculates the angle at the center of this bin (each bin spans 45 degrees)
let radius = bin.avgDist; const minR = min(width, height) * 0.18; const maxR = min(width, height) * 0.4; if (!isFinite(radius) || radius < minR) radius = minR; if (radius > maxR) radius = maxR
- Sets the well radius based on average particle distance in that bin, clamped between 18% and 40% of screen size
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy)
- Creates a vector at the calculated angle and distance, then adds the canvas center to get the final position
suggestions.push({ x: pos.x, y: pos.y, radius: radius * 0.9, isRepulsor: false, type: 'symmetry' })
- Adds a suggestion object for an attractor well (isRepulsor: false) at this position
aiMode = 'symmetry'
- Sets the AI mode to 'symmetry' so the HUD displays the current suggestion type
function generateChaosSuggestions() {
if (particles.length === 0) return;
const bins = analyzeFlow();
let indices = [...Array(NUM_BINS).keys()];
indices.sort(
(a, b) =>
bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
);
const cx = width * 0.5;
const cy = height * 0.5;
const numSuggestions = 5;
suggestions = [];
for (let i = 0; i < numSuggestions; i++) {
const idx = indices[i % indices.length];
const bin = bins[idx];
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
let radius = bin.avgDist * 0.9;
const minR = min(width, height) * 0.15;
const maxR = min(width, height) * 0.45;
if (!isFinite(radius) || radius < minR) radius = minR;
if (radius > maxR) radius = maxR;
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
const isRep = i % 2 === 0;
suggestions.push({
x: pos.x,
y: pos.y,
radius: radius * 0.8,
isRepulsor: isRep,
type: 'chaos'
});
}
aiMode = 'chaos';
}
๐ง Subcomponents:
calculation
Analyze Turbulence Score
indices.sort((a, b) => bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count)
Sorts bins by turbulence (speed ร particle count), identifying the most chaotic areas
for-loop
Generate Chaos Well Suggestions
for (let i = 0; i < numSuggestions; i++) { ... const isRep = i % 2 === 0; suggestions.push({ ... }); }
Creates 5 alternating attractor/repulsor wells positioned in the most turbulent sectors
Line by Line:
if (particles.length === 0) return
- Exits early if there are no particles to analyze
const bins = analyzeFlow()
- Calls analyzeFlow() to get statistics about particles in each angular direction
let indices = [...Array(NUM_BINS).keys()]
- Creates an array [0, 1, 2, 3, 4, 5, 6, 7] representing all 8 bins
indices.sort((a, b) => bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count)
- Sorts indices by turbulence score (speed ร particle count), putting the most chaotic sectors first
const numSuggestions = 5
- Sets the number of suggested wells to 5 (one more than symmetry mode)
for (let i = 0; i < numSuggestions; i++)
- Loops 5 times to create 5 suggestions
const idx = indices[i % indices.length]
- Gets the i-th most turbulent bin index
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS)
- Calculates the angle at the center of this bin
let radius = bin.avgDist * 0.9; const minR = min(width, height) * 0.15; const maxR = min(width, height) * 0.45; if (!isFinite(radius) || radius < minR) radius = minR; if (radius > maxR) radius = maxR
- Sets the well radius based on average particle distance, clamped between 15% and 45% of screen size (wider range than symmetry)
const isRep = i % 2 === 0
- Alternates between repulsor (true) and attractor (false) for each suggestion
suggestions.push({ x: pos.x, y: pos.y, radius: radius * 0.8, isRepulsor: isRep, type: 'chaos' })
- Adds a suggestion object with alternating repulsor/attractor types
aiMode = 'chaos'
- Sets the AI mode to 'chaos' so the HUD displays the current suggestion type
function drawSuggestions() {
if (suggestions.length === 0) return;
const t = frameCount * 0.08;
const pulse = map(sin(t), -1, 1, 0.7, 1.2);
noFill();
strokeWeight(1.4);
for (let s of suggestions) {
const r = s.radius * pulse;
const baseAlpha = 45;
if (s.type === 'symmetry') {
stroke(190, 100, 100, baseAlpha);
} else {
stroke(320, 100, 100, baseAlpha);
}
ellipse(s.x, s.y, r * 2, r * 2);
noStroke();
if (s.isRepulsor) {
fill(320, 100, 100, 80);
} else {
fill(190, 100, 100, 80);
}
ellipse(s.x, s.y, 6, 6);
}
}
๐ง Subcomponents:
calculation
Pulsing Animation Effect
const t = frameCount * 0.08; const pulse = map(sin(t), -1, 1, 0.7, 1.2)
Creates a pulsing animation that makes suggestion circles grow and shrink to draw attention
for-loop
Draw Each Suggestion Circle
for (let s of suggestions) { ... ellipse(s.x, s.y, r * 2, r * 2); ... ellipse(s.x, s.y, 6, 6); }
Draws each suggestion as a pulsing circle with a colored core, using different colors for symmetry vs chaos
Line by Line:
if (suggestions.length === 0) return
- Exits early if there are no suggestions to draw
const t = frameCount * 0.08
- Converts frameCount to a time value that increases slowly
const pulse = map(sin(t), -1, 1, 0.7, 1.2)
- Uses sine wave to create a pulsing value that oscillates between 0.7 and 1.2, making circles grow and shrink
noFill(); strokeWeight(1.4)
- Sets up drawing with no fill and thin stroke weight
for (let s of suggestions)
- Loops through each suggestion to draw it
const r = s.radius * pulse
- Multiplies the suggestion's radius by the pulse value to create the pulsing effect
if (s.type === 'symmetry') { stroke(190, 100, 100, baseAlpha); } else { stroke(320, 100, 100, baseAlpha); }
- Uses cyan (hue 190) for symmetry suggestions and magenta (hue 320) for chaos suggestions
ellipse(s.x, s.y, r * 2, r * 2)
- Draws the pulsing circle outline at the suggestion's position
if (s.isRepulsor) { fill(320, 100, 100, 80); } else { fill(190, 100, 100, 80); }
- Colors the center dot magenta for repulsors and cyan for attractors
ellipse(s.x, s.y, 6, 6)
- Draws a 6-pixel colored dot at the suggestion's center
function drawHUD() {
fill(0, 0, 100, 80);
noStroke();
textSize(12);
textAlign(LEFT, TOP);
let lines = [];
lines.push('AI GRAVITY PAINTER');
lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`);
lines.push(`Wells/zones: ${fields.length}`);
let aiLabel = 'OFF';
if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)';
else if (aiMode === 'chaos') aiLabel = 'Chaos (2)';
lines.push(`AI suggestions: ${aiLabel}`);
if (crystallized) {
lines.push('Mode: CRYSTALLIZED (press C to return to fluid)');
} else {
lines.push('Mode: Fluid (press C to crystallize geometry)');
}
if (showHelp) {
lines.push('');
lines.push('Controls:');
lines.push(' Click: add gravity well (attractor)');
lines.push(' Drag: create repulsion zone');
lines.push(' 1: AI symmetry suggestions');
lines.push(' 2: AI chaos suggestions');
lines.push(' ENTER: apply AI suggestions');
lines.push(' C: crystallize / un-crystallize');
lines.push(' R: reset particles & fields');
lines.push(' H: toggle this help');
} else {
lines.push('');
lines.push('Press H for help');
}
let y = 10;
for (let line of lines) {
text(line, 12, y);
y += 15;
}
}
๐ง Subcomponents:
calculation
Set Up Text Styling
fill(0, 0, 100, 80); noStroke(); textSize(12); textAlign(LEFT, TOP)
Configures text color (white with transparency), size, and alignment for the HUD
calculation
Build Status Information Lines
lines.push('AI GRAVITY PAINTER'); lines.push(...); let aiLabel = ...; lines.push(`AI suggestions: ${aiLabel}`); lines.push(...)
Creates an array of strings containing status information about particles, fields, and current mode
conditional
Show or Hide Help Text
if (showHelp) { lines.push(''); lines.push('Controls:'); ... } else { lines.push(''); lines.push('Press H for help'); }
Conditionally includes full control instructions or a brief prompt based on showHelp flag
for-loop
Render All HUD Lines
let y = 10; for (let line of lines) { text(line, 12, y); y += 15; }
Draws each line of text at the correct vertical position, spacing them 15 pixels apart
Line by Line:
fill(0, 0, 100, 80)
- Sets text color to white (100 brightness) with 80% opacity for a semi-transparent effect
noStroke(); textSize(12); textAlign(LEFT, TOP)
- Removes text outline, sets font size to 12 pixels, and aligns text to the top-left corner
let lines = []
- Creates an empty array to collect all the text lines to display
lines.push('AI GRAVITY PAINTER')
- Adds the title to the HUD
lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`)
- Adds a line showing particle count, padded to 3 digits with spaces for alignment
lines.push(`Wells/zones: ${fields.length}`)
- Adds a line showing the number of gravity wells and repulsion zones
let aiLabel = 'OFF'; if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)'; else if (aiMode === 'chaos') aiLabel = 'Chaos (2)'; lines.push(`AI suggestions: ${aiLabel}`)
- Sets aiLabel based on current AI mode, then adds it to the HUD
if (crystallized) { lines.push('Mode: CRYSTALLIZED (press C to return to fluid)'); } else { lines.push('Mode: Fluid (press C to crystallize geometry)'); }
- Shows the current mode (crystallized or fluid) and how to toggle it
if (showHelp) { lines.push(''); lines.push('Controls:'); lines.push(' Click: add gravity well (attractor)'); ... } else { lines.push(''); lines.push('Press H for help'); }
- If showHelp is true, adds detailed control instructions. Otherwise, just prompts the user to press H
let y = 10; for (let line of lines) { text(line, 12, y); y += 15; }
- Draws each line of text starting at y=10, incrementing y by 15 pixels for each line to space them evenly
function mouseReleased() {
if (crystallized) return;
if (!isDragging || !dragStart) return;
const dx = mouseX - dragStart.x;
const dy = mouseY - dragStart.y;
const dragDist = sqrt(dx * dx + dy * dy);
const baseStrength = 2000;
const baseRadius = min(width, height) * 0.2;
if (dragDist < dragThreshold) {
const radius = baseRadius;
fields.push(new Field(mouseX, mouseY, baseStrength, radius, false));
} else {
const maxR = min(width, height) * 0.45;
const radius = constrain(dragDist, 40, maxR);
fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true));
}
isDragging = false;
dragStart = null;
}
๐ง Subcomponents:
calculation
Calculate Total Drag Distance
const dx = mouseX - dragStart.x; const dy = mouseY - dragStart.y; const dragDist = sqrt(dx * dx + dy * dy)
Calculates how far the mouse moved from the starting position
conditional
Distinguish Between Click and Drag
if (dragDist < dragThreshold) { ... } else { ... }
If distance is small (< 12 pixels), treats it as a click (creates attractor). If larger, treats it as a drag (creates repulsor)
calculation
Create Attractor Well on Click
const radius = baseRadius; fields.push(new Field(mouseX, mouseY, baseStrength, radius, false))
Creates an attractor well at the click position with a fixed radius
calculation
Create Repulsor Zone on Drag
const maxR = min(width, height) * 0.45; const radius = constrain(dragDist, 40, maxR); fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true))
Creates a repulsor zone at the drag start position with radius equal to the drag distance
Line by Line:
if (crystallized) return
- Exits if in crystallized mode
if (!isDragging || !dragStart) return
- Exits if no drag operation was started (safety check)
const dx = mouseX - dragStart.x; const dy = mouseY - dragStart.y
- Calculates the x and y distance the mouse moved
const dragDist = sqrt(dx * dx + dy * dy)
- Calculates the total distance moved using the Pythagorean theorem
const baseStrength = 2000; const baseRadius = min(width, height) * 0.2
- Sets default strength and radius values for created fields
if (dragDist < dragThreshold)
- Checks if the drag distance is less than 12 pixels (the threshold for a click)
const radius = baseRadius; fields.push(new Field(mouseX, mouseY, baseStrength, radius, false))
- If it's a click, creates an attractor well (isRepulsor: false) at the current mouse position with fixed radius
const maxR = min(width, height) * 0.45; const radius = constrain(dragDist, 40, maxR)
- If it's a drag, sets the radius to the drag distance, clamped between 40 and 45% of screen size
fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true))
- Creates a repulsor zone (isRepulsor: true) at the drag starting position with the calculated radius
isDragging = false; dragStart = null
- Resets the dragging state and clears the stored drag start position
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.prevPos = this.pos.copy();
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
this.acc = createVector(0, 0);
this.size = random(1.0, 2.5);
}
applyField(field) {
field.applyTo(this);
}
update() {
this.prevPos.set(this.pos);
this.vel.add(this.acc);
this.vel.mult(0.99);
const maxSpeed = 8;
if (this.vel.magSq() > maxSpeed * maxSpeed) {
this.vel.setMag(maxSpeed);
}
this.pos.add(this.vel);
this.acc.mult(0);
if (this.pos.x < 0) this.pos.x += width;
if (this.pos.x > width) this.pos.x -= width;
if (this.pos.y < 0) this.pos.y += height;
if (this.pos.y > height) this.pos.y -= height;
}
draw() {
const speed = this.vel.mag();
const norm = constrain(speed / 8, 0, 1);
const hue = lerp(180, 320, norm);
const bright = lerp(60, 100, norm);
const alpha = lerp(30, 90, norm);
stroke(hue, 100, bright, alpha);
strokeWeight(this.size);
line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y);
noStroke();
fill(hue, 100, bright, 30);
ellipse(this.pos.x, this.pos.y, this.size * 3, this.size * 3);
}
}
๐ง Subcomponents:
calculation
Particle Constructor
constructor(x, y) { this.pos = createVector(x, y); this.prevPos = this.pos.copy(); this.vel = p5.Vector.random2D().mult(random(0.3, 2.0)); this.acc = createVector(0, 0); this.size = random(1.0, 2.5); }
Initializes a new particle with position, velocity, and size
calculation
Apply Field Method
applyField(field) { field.applyTo(this); }
Delegates to the field's applyTo method to apply forces to this particle
calculation
Update Particle Physics
update() { ... this.vel.add(this.acc); this.vel.mult(0.99); ... this.pos.add(this.vel); this.acc.mult(0); ... }
Updates particle position based on velocity, applies damping, limits max speed, and wraps around screen edges
calculation
Draw Particle Trail
draw() { ... const hue = lerp(180, 320, norm); ... line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y); ... ellipse(this.pos.x, this.pos.y, this.size * 3, this.size * 3); }
Draws the particle as a colored line from previous position to current position, with a glowing dot at current position
Line by Line:
constructor(x, y) { this.pos = createVector(x, y)
- Creates a new particle at the specified x,y position using a p5.Vector
this.prevPos = this.pos.copy()
- Stores a copy of the initial position for drawing motion trails
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0))
- Initializes velocity with a random direction and speed between 0.3 and 2.0 pixels per frame
this.acc = createVector(0, 0)
- Initializes acceleration to zero; it will be updated by gravity fields each frame
this.size = random(1.0, 2.5)
- Assigns a random size between 1.0 and 2.5 pixels for visual variety
applyField(field) { field.applyTo(this) }
- Simple method that delegates to the field's applyTo method to apply forces to this particle
this.prevPos.set(this.pos)
- Saves the current position as the previous position before updating
this.vel.add(this.acc)
- Applies acceleration to velocity (Newton's second law: a = dv/dt)
this.vel.mult(0.99)
- Applies damping/friction by multiplying velocity by 0.99, slowing particles over time
const maxSpeed = 8; if (this.vel.magSq() > maxSpeed * maxSpeed) { this.vel.setMag(maxSpeed) }
- Limits maximum speed to 8 pixels per frame to prevent particles from moving too fast
this.pos.add(this.vel)
- Updates position by adding velocity (position = position + velocity)
this.acc.mult(0)
- Resets acceleration to zero for the next frame (forces are recalculated each frame)
if (this.pos.x < 0) this.pos.x += width; if (this.pos.x > width) this.pos.x -= width
- Wraps particle horizontally: if it goes off the left edge, it reappears on the right, and vice versa
if (this.pos.y < 0) this.pos.y += height; if (this.pos.y > height) this.pos.y -= height
- Wraps particle vertically: if it goes off the top edge, it reappears at the bottom, and vice versa
const speed = this.vel.mag(); const norm = constrain(speed / 8, 0, 1)
- Calculates current speed and normalizes it to a 0-1 range for color mapping
const hue = lerp(180, 320, norm); const bright = lerp(60, 100, norm); const alpha = lerp(30, 90, norm)
- Creates a color gradient based on speed: slow particles are cyan (hue 180), fast particles are magenta (hue 320)
stroke(hue, 100, bright, alpha); strokeWeight(this.size); line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y)
- Draws a line from the previous position to current position with color and thickness based on speed
noStroke(); fill(hue, 100, bright, 30); ellipse(this.pos.x, this.pos.y, this.size * 3, this.size * 3)
- Draws a subtle glowing dot at the particle's current position with the same color as the trail
class Field {
constructor(x, y, strength, radius, isRepulsor = false) {
this.pos = createVector(x, y);
this.strength = strength;
this.radius = radius;
this.isRepulsor = isRepulsor;
}
applyTo(p) {
const dx = this.pos.x - p.pos.x;
const dy = this.pos.y - p.pos.y;
let distSq = dx * dx + dy * dy;
const radiusSq = this.radius * this.radius;
if (distSq > radiusSq) return;
const softening = 100;
distSq += softening;
let dist = sqrt(distSq);
if (dist === 0) return;
let dir = createVector(dx, dy);
dir.div(dist);
let falloff = 1 - distSq / (radiusSq + softening);
falloff = constrain(falloff, 0, 1);
let forceMag = (this.strength * falloff) / distSq;
if (this.isRepulsor) forceMag *= -1;
dir.mult(forceMag);
p.acc.add(dir);
}
draw() {
const alpha = 40;
noFill();
strokeWeight(1.5);
if (this.isRepulsor) {
stroke(320, 100, 100, alpha);
} else {
stroke(190, 100, 100, alpha);
}
ellipse(this.pos.x, this.pos.y, this.radius * 2, this.radius * 2);
if (this.isRepulsor) {
fill(320, 100, 100, 80);
} else {
fill(190, 100, 100, 80);
}
noStroke();
ellipse(this.pos.x, this.pos.y, 8, 8);
}
}
๐ง Subcomponents:
calculation
Field Constructor
constructor(x, y, strength, radius, isRepulsor = false) { this.pos = createVector(x, y); this.strength = strength; this.radius = radius; this.isRepulsor = isRepulsor; }
Initializes a gravity well or repulsion zone with position, strength, radius, and type
calculation
Apply Force to Particle
applyTo(p) { ... let falloff = 1 - distSq / (radiusSq + softening); ... let forceMag = (this.strength * falloff) / distSq; ... p.acc.add(dir); }
Calculates gravitational force on a particle and adds it to the particle's acceleration
calculation
Draw Field Visualization
draw() { ... if (this.isRepulsor) { stroke(320, 100, 100, alpha); } else { stroke(190, 100, 100, alpha); } ... ellipse(this.pos.x, this.pos.y, this.radius * 2, this.radius * 2); ... }
Draws the field as a circle outline (cyan for attractor, magenta for repulsor) with a colored core
Line by Line:
constructor(x, y, strength, radius, isRepulsor = false)
- Creates a new field at position (x,y) with specified strength and radius. isRepulsor defaults to false (attractor)
this.pos = createVector(x, y); this.strength = strength; this.radius = radius; this.isRepulsor = isRepulsor
- Stores the field's properties: position vector, strength (force magnitude), radius (influence area), and type
const dx = this.pos.x - p.pos.x; const dy = this.pos.y - p.pos.y; let distSq = dx * dx + dy * dy
- Calculates the squared distance from the field to the particle
const radiusSq = this.radius * this.radius; if (distSq > radiusSq) return
- If the particle is outside the field's radius of influence, exit early without applying force
const softening = 100; distSq += softening
- Adds softening to prevent infinite acceleration when particles get very close to the field center
let dist = sqrt(distSq); if (dist === 0) return
- Calculates the actual distance and exits if it's zero (particle is exactly at field center)
let dir = createVector(dx, dy); dir.div(dist)
- Creates a direction vector from particle to field and normalizes it to unit length
let falloff = 1 - distSq / (radiusSq + softening); falloff = constrain(falloff, 0, 1)
- Calculates falloff: force is strongest at center (falloff = 1) and weakens toward the edge (falloff = 0)
let forceMag = (this.strength * falloff) / distSq; if (this.isRepulsor) forceMag *= -1
- Calculates force magnitude using inverse-square law, then negates it if this is a repulsor
dir.mult(forceMag); p.acc.add(dir)
- Multiplies the direction by force magnitude and adds the resulting force vector to the particle's acceleration
const alpha = 40; noFill(); strokeWeight(1.5)
- Sets up drawing with transparent stroke and no fill
if (this.isRepulsor) { stroke(320, 100, 100, alpha); } else { stroke(190, 100, 100, alpha); }
- Uses magenta (hue 320) for repulsors and cyan (hue 190) for attractors
ellipse(this.pos.x, this.pos.y, this.radius * 2, this.radius * 2)
- Draws a circle outline showing the field's radius of influence
if (this.isRepulsor) { fill(320, 100, 100, 80); } else { fill(190, 100, 100, 80); } noStroke(); ellipse(this.pos.x, this.pos.y, 8, 8)
- Draws a small 8-pixel colored dot at the field's center to mark its position