AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we

AI Gravity Painter is an interactive physics sandbox where users paint with gravitational forces. Click to create gravity wells that attract particles, drag to create repulsion zones, and use AI suggestions to generate symmetrical or chaotic field patterns. Particles leave glowing trails as they move, and the sketch includes a crystallize mode that converts the particle system into geometric structures.

๐ŸŽ“ Concepts You'll Learn

Physics simulation with forces and accelerationParticle systems and collision detectionVector mathematics and velocity/accelerationInteractive mouse and keyboard inputAdditive blending and color gradientsSpatial analysis and binning algorithmsScreen wrapping and boundary conditionsGeometric graph generationHSB color mode and dynamic coloringAnimation with frameCount and trigonometric functions

๐Ÿ”„ Code Flow

Code flow showing setup, draw, windowresized, initparticles, handlecollisions, togglecrystallize, buildcrystalgeometry, drawcrystals, analyzeflow, generatesymmetrysuggestions, generatechaossuggestions, applysuggestions, drawsuggestions, drawhud, mousepressed, mousereleased, mousedragged, keypressed, particle, field

๐Ÿ’ก Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> fading-background[Fading Background Trail Effect] draw --> additive-blending[Additive Blend Mode Setup] draw --> fluid-mode-loop[Fluid Physics Loop] fluid-mode-loop --> field-application[Apply All Fields to Each Particle] field-application --> particle[particle] particle --> applyfield-method[Apply Field Method] particle --> update-method[Update Particle Physics] particle --> draw-method[Draw Particle Trail] fluid-mode-loop --> handlecollisions[handleCollisions] handlecollisions --> max-particle-check[Prevent Exceeding Max Particles] max-particle-check --> collision-detection-loop[Nested Loop Collision Detection] collision-detection-loop --> distance-calculation[Distance Squared Calculation] distance-calculation --> collision-spawn[Spawn New Particle on Collision] collision-spawn --> separation-impulse[Separating Impulse] draw --> crystal-mode[Crystallize Mode Branch] crystal-mode --> build-or-clear[Build or Clear Crystal Geometry] build-or-clear --> buildcrystalgeometry[buildCrystalGeometry] buildcrystalgeometry --> nearest-neighbor-search[Find Nearest Neighbors for Each Particle] nearest-neighbor-search --> sort-by-distance[Sort Neighbors by Distance] sort-by-distance --> connect-nearest[Connect to K Nearest Neighbors] draw --> drawcrystals[drawCrystals] drawcrystals --> fading-crystal-background[Fading Background for Crystal Mode] fading-crystal-background --> animated-hue[Animated Hue Based on Frame Count] animated-hue --> crystal-line-loop[Draw Each Crystal Line with Nodes] draw --> analyzeflow[analyzeFlow] analyzeflow --> bin-initialization[Initialize Empty Bins] bin-initialization --> particle-binning[Categorize Particles into Angular Bins] particle-binning --> average-calculation[Calculate Average Distance and Speed per Bin] draw --> generatesymmetrysuggestions[generateSymmetrySuggestions] generatesymmetrysuggestions --> sort-by-density[Sort Bins by Particle Density] sort-by-density --> suggestion-generation[Generate Symmetry Well Suggestions] draw --> generatechaossuggestions[generateChaosSuggestions] generatechaossuggestions --> turbulence-analysis[Analyze Turbulence Score] turbulence-analysis --> chaos-suggestion-generation[Generate Chaos Well Suggestions] draw --> applysuggestions[applySuggestions] applysuggestions --> suggestion-to-field[Convert Suggestions to Actual Fields] draw --> drawsuggestions[drawSuggestions] drawsuggestions --> pulsing-animation[Pulsing Animation Effect] pulsing-animation --> suggestion-drawing-loop[Draw Each Suggestion Circle] draw --> drawhud[drawHUD] drawhud --> text-styling[Set Up Text Styling] text-styling --> status-lines[Build Status Information Lines] status-lines --> help-conditional[Show or Hide Help Text] help-conditional --> text-rendering-loop[Render All HUD Lines] draw --> windowresized[windowResized] windowresized --> resizeCanvas[Resize Canvas] mousepressed[mousePressed] --> left-button-check[Check for Left Mouse Button] left-button-check --> drag-distance-calculation[Calculate Total Drag Distance] drag-distance-calculation --> click-vs-drag-decision[Distinguish Between Click and Drag] click-vs-drag-decision --> click-attractor-creation[Create Attractor Well on Click] click-vs-drag-decision --> drag-repulsor-creation[Create Repulsor Zone on Drag] keypressed[keyPressed] --> symmetry-key[Key 1: Symmetry Suggestions] keypressed --> chaos-key[Key 2: Chaos Suggestions] keypressed --> apply-key[ENTER: Apply Suggestions] keypressed --> crystallize-key[Key C: Toggle Crystallize] keypressed --> reset-key[Key R: Reset Everything] keypressed --> help-key[Key H: Toggle Help] click setup href "#fn-setup" click draw href "#fn-draw" click fading-background href "#sub-fading-background" click additive-blending href "#sub-additive-blending" click fluid-mode-loop href "#sub-fluid-mode-loop" click field-application href "#sub-field-application" click handlecollisions href "#fn-handlecollisions" click max-particle-check href "#sub-max-particle-check" click collision-detection-loop href "#sub-collision-detection-loop" click distance-calculation href "#sub-distance-calculation" click collision-spawn href "#sub-collision-spawn" click separation-impulse href "#sub-separation-impulse" click crystal-mode href "#sub-crystal-mode" click build-or-clear href "#sub-build-or-clear" click buildcrystalgeometry href "#fn-buildcrystalgeometry" click nearest-neighbor-search href "#sub-nearest-neighbor-search" click sort-by-distance href "#sub-sort-by-distance" click connect-nearest href "#sub-connect-nearest" click drawcrystals href "#fn-drawcrystals" click fading-crystal-background href "#sub-fading-crystal-background" click animated-hue href "#sub-animated-hue" click crystal-line-loop href "#sub-crystal-line-loop" click analyzeflow href "#fn-analyzeflow" click bin-initialization href "#sub-bin-initialization" click particle-binning href "#sub-particle-binning" click average-calculation href "#sub-average-calculation" click generatesymmetrysuggestions href "#fn-generatesymmetrysuggestions" click sort-by-density href "#sub-sort-by-density" click suggestion-generation href "#sub-suggestion-generation" click generatechaossuggestions href "#fn-generatechaossuggestions" click turbulence-analysis href "#sub-turbulence-analysis" click chaos-suggestion-generation href "#sub-chaos-suggestion-generation" click applysuggestions href "#fn-applysuggestions" click suggestion-to-field href "#sub-suggestion-to-field" click drawsuggestions href "#fn-drawsuggestions" click pulsing-animation href "#sub-pulsing-animation" click suggestion-drawing-loop href "#sub-suggestion-drawing-loop" click drawhud href "#fn-drawhud" click text-styling href "#sub-text-styling" click status-lines href "#sub-status-lines" click help-conditional href "#sub-help-conditional" click text-rendering-loop href "#sub-text-rendering-loop" click windowresized href "#fn-windowresized" click mousepressed href "#fn-mousepressed" click left-button-check href "#sub-left-button-check" click drag-distance-calculation href "#sub-drag-distance-calculation" click click-vs-drag-decision href "#sub-click-vs-drag-decision" click click-attractor-creation href "#sub-click-attractor-creation" click drag-repulsor-creation href "#sub-drag-repulsor-creation" click keypressed href "#fn-keypressed" click symmetry-key href "#sub-symmetry-key" click chaos-key href "#sub-chaos-key" click apply-key href "#sub-apply-key" click crystallize-key href "#sub-crystallize-key" click reset-key href "#sub-reset-key" click help-key href "#sub-help-key"

๐Ÿ“ Code Breakdown

setup()

setup() runs once when the sketch starts. It's where you initialize the canvas size, set color modes, and create initial objects. The HSB color mode is particularly useful here because it lets you easily create smooth color transitions based on particle velocity.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);
  background(0);
  initParticles(numInitialParticles);
}

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a canvas that fills the entire browser window, allowing the sketch to use the full screen space
colorMode(HSB, 360, 100, 100, 100)
Switches from RGB to HSB (Hue, Saturation, Brightness) color mode with ranges 0-360 for hue and 0-100 for saturation, brightness, and alpha. This makes it easier to create color gradients
background(0)
Fills the canvas with black (0 brightness in HSB mode), creating the initial dark background
initParticles(numInitialParticles)
Calls the initialization function to create 450 particles scattered randomly across the canvas

draw()

draw() runs 60 times per second and is the main animation loop. It applies physics forces, updates positions, detects collisions, and renders everything. The fading background with low alpha creates motion trails, while additive blending makes particles glow. The conditional check for crystallized mode lets you switch between two completely different visual styles.

function draw() {
  background(0, 0, 0, 18);
  blendMode(ADD);

  if (!crystallized) {
    for (let p of particles) {
      for (let f of fields) {
        p.applyField(f);
      }
      p.update();
    }

    handleCollisions();

    for (let p of particles) {
      p.draw();
    }

    for (let f of fields) {
      f.draw();
    }
  } else {
    drawCrystals();
  }

  drawSuggestions();
  blendMode(BLEND);
  drawHUD();
}

๐Ÿ”ง Subcomponents:

calculation Fading Background Trail Effect background(0, 0, 0, 18)

Clears the canvas with semi-transparent black (alpha 18/100), creating a fade trail effect where old particle paths gradually disappear

calculation Additive Blend Mode Setup blendMode(ADD)

Switches to additive blending so particles and fields glow and combine their colors, creating a neon effect

for-loop Fluid Physics Loop if (!crystallized) { ... }

When not crystallized, applies forces to particles, updates their positions, handles collisions, and draws the animated system

for-loop Apply All Fields to Each Particle for (let p of particles) { for (let f of fields) { p.applyField(f); } p.update(); }

For each particle, applies the influence of every gravity well and repulsion zone, then updates the particle's position

conditional Crystallize Mode Branch } else { drawCrystals(); }

When crystallized is true, switches to drawing geometric crystal structures instead of fluid particle animation

Line by Line:

background(0, 0, 0, 18)
Creates a fading trail effect by drawing a semi-transparent black rectangle over the canvas each frame. The low alpha (18) means old particles fade gradually instead of disappearing instantly
blendMode(ADD)
Switches to additive blending mode, which adds color values together instead of replacing them. This creates the glowing neon effect where particles and fields brighten each other
if (!crystallized)
Checks if the sketch is in fluid mode (not crystallized). If true, runs the physics simulation; if false, switches to crystal geometry mode
for (let p of particles) { for (let f of fields) { p.applyField(f); } p.update(); }
For each particle, loops through every gravity well and repulsion zone, applying their forces. Then updates the particle's velocity and position based on accumulated acceleration
handleCollisions()
Checks for particles that collide with each other and spawns new particles at collision points, creating growth in the system
for (let p of particles) { p.draw(); }
Draws each particle as a glowing line from its previous position to current position, creating motion trails
for (let f of fields) { f.draw(); }
Draws all gravity wells and repulsion zones as circles with colored cores, showing where forces are active
drawCrystals()
When crystallized, draws the geometric crystal structure instead of particles
drawSuggestions()
Draws the pulsing ghost wells/zones that represent AI suggestions waiting to be applied
blendMode(BLEND)
Switches back to normal blending mode so the HUD text displays clearly without glow effects
drawHUD()
Draws the heads-up display showing particle count, controls, and current mode

windowResized()

windowResized() is a special p5.js function that automatically runs whenever the browser window is resized. By calling resizeCanvas(), you ensure the sketch always fills the available space and adapts to different screen sizes.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  background(0);
}

Line by Line:

resizeCanvas(windowWidth, windowHeight)
Automatically resizes the canvas when the browser window is resized, keeping the sketch responsive
background(0)
Clears the canvas with black background after resizing to prevent visual artifacts

initParticles(count)

initParticles() is a helper function that populates the particles array with a specified number of new particles. It's called during setup() to create the initial 450 particles, and again when the user presses R to reset. The random() function ensures particles start at different positions.

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

handleCollisions()

handleCollisions() uses a nested loop to check every pair of particles for collisions. This is O(nยฒ) complexity, which is expensive but necessary for accurate collision detection. When particles collide with sufficient speed, a new particle spawns at the collision point, creating growth in the system. The separation impulse prevents particles from getting stuck together.

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

toggleCrystallize()

toggleCrystallize() switches between two visual modes. In fluid mode, particles animate under physics forces. In crystallized mode, particles freeze and are connected by lines to create a geometric crystal structure. Pressing C toggles between these modes.

function toggleCrystallize() {
  crystallized = !crystallized;
  if (crystallized) {
    buildCrystalGeometry();
  } else {
    crystalLines = [];
    background(0);
  }
}

๐Ÿ”ง Subcomponents:

calculation Toggle Crystallized State crystallized = !crystallized

Flips the crystallized boolean between true and false

conditional Build or Clear Crystal Geometry if (crystallized) { buildCrystalGeometry(); } else { crystalLines = []; background(0); }

When entering crystal mode, builds the geometry. When exiting, clears it and resets the canvas

Line by Line:

crystallized = !crystallized
Toggles the crystallized variable: if it was true, it becomes false, and vice versa
if (crystallized) { buildCrystalGeometry(); }
When entering crystallized mode, calls buildCrystalGeometry() to create the geometric structure
} else { crystalLines = []; background(0); }
When exiting crystallized mode, clears the crystal lines array and resets the canvas to black

buildCrystalGeometry()

buildCrystalGeometry() creates a geometric structure by connecting each particle to its 2 nearest neighbors. This creates a natural-looking crystal lattice. The key optimization is the if (i < neighborIndex) check, which prevents storing duplicate connections. This algorithm is O(nยฒ log n) due to sorting, but it only runs once when crystallize mode is activated.

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:

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)

drawCrystals()

drawCrystals() renders the geometric crystal structure with animated colors. The sine wave creates a smooth oscillation between cyan and magenta hues, making the crystals appear to shimmer. Small nodes at line midpoints enhance the crystalline appearance. The very transparent background (alpha 10) creates a slow fade effect.

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

analyzeFlow()

analyzeFlow() divides the canvas into 8 angular sectors (like pizza slices) and analyzes particle distribution in each sector. It calculates how many particles are in each direction, their average distance from center, and their average speed. This data is used by the AI suggestion functions to determine where to place gravity wells for symmetry or chaos effects.

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

generateSymmetrySuggestions()

generateSymmetrySuggestions() uses flow analysis to identify the least populated areas of the canvas, then suggests placing attractor wells there to create visual balance. It sorts bins by particle count and places wells in the emptiest sectors, creating a symmetrical composition. The wells are positioned at distances matching the average particle distance in each sector.

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

generateChaosSuggestions()

generateChaosSuggestions() identifies the most turbulent areas (where particles are moving fastest and densest) and suggests placing alternating attractor/repulsor wells there to amplify the chaos. Unlike symmetry mode which balances the system, chaos mode intensifies existing motion patterns.

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

applySuggestions()

applySuggestions() converts the ghost suggestions into actual gravity wells and repulsion zones. It's called when the user presses ENTER. Each suggestion becomes a Field object with the same position, radius, and repulsor type, but with a fixed strength of 1600.

function applySuggestions() {
  if (suggestions.length === 0) return;

  const baseStrength = 1600;

  for (let s of suggestions) {
    fields.push(
      new Field(s.x, s.y, baseStrength, s.radius, s.isRepulsor)
    );
  }

  suggestions = [];
  aiMode = 'none';
}

๐Ÿ”ง Subcomponents:

for-loop Convert Suggestions to Actual Fields for (let s of suggestions) { fields.push(new Field(s.x, s.y, baseStrength, s.radius, s.isRepulsor)); }

Converts each suggestion object into an actual Field object and adds it to the fields array

Line by Line:

if (suggestions.length === 0) return
Exits early if there are no suggestions to apply
const baseStrength = 1600
Sets the strength of all applied fields to 1600 (controls how strongly they affect particles)
for (let s of suggestions) { fields.push(new Field(s.x, s.y, baseStrength, s.radius, s.isRepulsor)); }
Loops through each suggestion and creates a new Field object with the suggestion's position, radius, and repulsor type, then adds it to the fields array
suggestions = []
Clears the suggestions array since they've been applied
aiMode = 'none'
Resets AI mode to 'none' since the suggestions have been consumed

drawSuggestions()

drawSuggestions() visualizes the AI suggestions as pulsing ghost wells. The pulsing animation (using sine wave) draws the user's attention to the suggestions. Cyan circles represent symmetry suggestions (attractors), while magenta circles represent chaos suggestions (which alternate between attractors and repulsors).

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

drawHUD()

drawHUD() renders the heads-up display showing status information and controls. It uses a lines array to build all the text content, then draws it line by line with proper spacing. The help text can be toggled with H to reduce visual clutter.

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

mousePressed()

mousePressed() is called when the user clicks the mouse. It records the starting position and sets a flag indicating that dragging has begun. The actual distinction between a click and a drag is determined in mouseReleased() based on the distance moved.

function mousePressed() {
  if (crystallized) return;
  if (mouseButton !== LEFT) return;

  dragStart = createVector(mouseX, mouseY);
  isDragging = true;
}

๐Ÿ”ง Subcomponents:

conditional Prevent Interaction in Crystallized Mode if (crystallized) return

Freezes all mouse interaction when the sketch is in crystallized mode

conditional Check for Left Mouse Button if (mouseButton !== LEFT) return

Only responds to left mouse button clicks, ignoring right-click and middle-click

Line by Line:

if (crystallized) return
Exits the function if in crystallized mode, preventing any mouse interaction
if (mouseButton !== LEFT) return
Exits if the mouse button clicked is not the left button (ignores right-click, middle-click, etc.)
dragStart = createVector(mouseX, mouseY)
Records the starting position of the mouse drag as a vector
isDragging = true
Sets the isDragging flag to true, indicating that a drag operation has started

mouseReleased()

mouseReleased() is called when the user releases the mouse button. It determines whether the user clicked (short distance) or dragged (long distance). Clicks create attractor wells with fixed size, while drags create repulsor zones with size proportional to drag distance. The dragThreshold of 12 pixels prevents accidental drags from being treated as clicks.

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

mouseDragged()

mouseDragged() is a p5.js function that runs continuously while the mouse is being dragged. In this sketch, it only checks if crystallized mode is active. The actual drag logic is handled in mouseReleased().

function mouseDragged() {
  if (crystallized) return;
}

Line by Line:

if (crystallized) return
Prevents dragging interaction when in crystallized mode

keyPressed()

keyPressed() is called whenever the user presses a key. This sketch uses it to handle all keyboard controls: 1 and 2 for AI suggestions, ENTER to apply them, C to toggle crystallize mode, R to reset, and H to toggle help. Using else if chains ensures only one action per keypress.

function keyPressed() {
  if (key === '1') {
    generateSymmetrySuggestions();
  } else if (key === '2') {
    generateChaosSuggestions();
  } else if (keyCode === ENTER) {
    applySuggestions();
  } else if (key === 'C' || key === 'c') {
    toggleCrystallize();
  } else if (key === 'R' || key === 'r') {
    fields = [];
    suggestions = [];
    crystallized = false;
    crystalLines = [];
    initParticles(numInitialParticles);
    background(0);
  } else if (key === 'H' || key === 'h') {
    showHelp = !showHelp;
  }
}

๐Ÿ”ง Subcomponents:

conditional Key 1: Symmetry Suggestions if (key === '1') { generateSymmetrySuggestions(); }

Generates symmetry-based AI suggestions when user presses 1

conditional Key 2: Chaos Suggestions } else if (key === '2') { generateChaosSuggestions(); }

Generates chaos-based AI suggestions when user presses 2

conditional ENTER: Apply Suggestions } else if (keyCode === ENTER) { applySuggestions(); }

Applies current AI suggestions when user presses ENTER

conditional Key C: Toggle Crystallize } else if (key === 'C' || key === 'c') { toggleCrystallize(); }

Toggles between fluid and crystallized modes when user presses C

conditional Key R: Reset Everything } else if (key === 'R' || key === 'r') { fields = []; suggestions = []; crystallized = false; crystalLines = []; initParticles(numInitialParticles); background(0); }

Resets all particles, fields, and suggestions to initial state when user presses R

conditional Key H: Toggle Help } else if (key === 'H' || key === 'h') { showHelp = !showHelp; }

Toggles help text visibility when user presses H

Line by Line:

if (key === '1') { generateSymmetrySuggestions(); }
When user presses 1, generates symmetry-based AI suggestions
} else if (key === '2') { generateChaosSuggestions(); }
When user presses 2, generates chaos-based AI suggestions
} else if (keyCode === ENTER) { applySuggestions(); }
When user presses ENTER, applies the current AI suggestions as actual gravity wells
} else if (key === 'C' || key === 'c') { toggleCrystallize(); }
When user presses C (uppercase or lowercase), toggles between fluid and crystallized modes
} else if (key === 'R' || key === 'r') { fields = []; suggestions = []; crystallized = false; crystalLines = []; initParticles(numInitialParticles); background(0); }
When user presses R, completely resets the sketch: clears all fields and suggestions, returns to fluid mode, recreates initial particles, and clears the canvas
} else if (key === 'H' || key === 'h') { showHelp = !showHelp; }
When user presses H, toggles the showHelp flag to show or hide the control instructions

class Particle

The Particle class represents individual particles in the system. Each particle has position, velocity, and acceleration vectors. The update() method implements physics: applying forces, damping, speed limiting, and screen wrapping. The draw() method creates motion trails by drawing lines from previous to current position, with colors that change based on velocity (slow = cyan, fast = magenta). This creates the visual effect of particles glowing brighter when moving faster.

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

The Field class represents gravity wells (attractors) and repulsion zones (repulsors). The applyTo() method implements gravitational physics: it calculates distance-based force using an inverse-square law with softening to prevent singularities. The falloff function makes force strongest at the center and weaker at the edges. Repulsors simply negate the force direction. The draw() method visualizes fields as circles with colored cores.

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

๐Ÿ“ฆ Key Variables

particles array

Stores all Particle objects currently in the system. Each particle has position, velocity, and size.

let particles = [];
fields array

Stores all Field objects (gravity wells and repulsion zones) that affect particles.

let fields = [];
suggestions array

Stores AI-generated suggestions for gravity wells and repulsion zones waiting to be applied.

let suggestions = [];
numInitialParticles number

The starting number of particles created in setup(). Set to 450.

let numInitialParticles = 450;
maxParticles number

Maximum particle count allowed. Prevents system from becoming too heavy. Set to 900.

let maxParticles = 900;
isDragging boolean

Tracks whether the user is currently dragging the mouse.

let isDragging = false;
dragStart object/vector

Stores the starting position of a mouse drag as a p5.Vector. Used to calculate drag distance.

let dragStart = null;
dragThreshold number

Distance in pixels below which a mouse movement is considered a click rather than a drag. Set to 12.

let dragThreshold = 12;
crystallized boolean

Tracks whether the sketch is in crystallized mode (geometric structure) or fluid mode (physics animation).

let crystallized = false;
crystalLines array

Stores line segments connecting particles in crystallized mode. Each element has x1, y1, x2, y2 coordinates.

let crystalLines = [];
showHelp boolean

Controls whether the help text is displayed in the HUD. Toggled by pressing H.

let showHelp = true;
aiMode string

Tracks the current AI suggestion mode: 'none', 'symmetry', or 'chaos'.

let aiMode = 'none';
NUM_BINS number

Number of angular sectors used for flow analysis. Set to 8 (divides canvas into 8 pie slices).

const NUM_BINS = 8;

๐Ÿงช Try This!

Experiment with the code by making these changes:

  1. Change numInitialParticles from 450 to 100 or 800 to see how particle density affects the visual effect and performance. Fewer particles create a sparser look, while more create a denser composition.
  2. Modify the dragThreshold from 12 to 30 or 5 to change how sensitive the click vs. drag detection is. Higher values make it easier to create attractors by clicking.
  3. In the Particle.draw() method, change the hue range from lerp(180, 320, norm) to lerp(0, 120, norm) to create a red-to-green color gradient instead of cyan-to-magenta.
  4. In the Field.applyTo() method, change the softening value from 100 to 500 to reduce the intensity of forces near the field center, creating gentler gravity effects.
  5. Increase maxParticles from 900 to 2000 to allow the system to grow larger through collisions, creating denser particle clouds.
  6. In generateSymmetrySuggestions(), change numSuggestions from 4 to 8 to create more balanced wells around the canvas.
  7. Modify the baseStrength in mouseReleased() from 2000 to 3000 to make manually-created wells stronger.
  8. In the Particle constructor, change the initial velocity from p5.Vector.random2D().mult(random(0.3, 2.0)) to p5.Vector.random2D().mult(random(0.1, 0.5)) to make particles start with less initial motion.
  9. Change the damping factor in Particle.update() from 0.99 to 0.95 to make particles slow down faster, or 0.999 to make them move longer.
  10. In drawCrystals(), modify the baseHue oscillation from (200 + 40 * sin(t)) to (200 + 80 * sin(t)) to create a wider color range in crystallized mode.
Open in Editor & Experiment โ†’

๐Ÿ”ง Potential Improvements

Here are some ways this code could be enhanced:

PERFORMANCE handleCollisions()

The nested loop checking all particle pairs is O(nยฒ) complexity. With 900 particles, this means 405,000 comparisons per frame, which can be slow.

๐Ÿ’ก Implement spatial partitioning (divide canvas into grid cells) to only check nearby particles for collisions. This would reduce comparisons from O(nยฒ) to approximately O(n).

BUG buildCrystalGeometry()

If particles.length is very large (900+), sorting all neighbors for each particle is O(nยฒ log n), which could cause frame rate drops when crystallizing.

๐Ÿ’ก Use a more efficient nearest-neighbor algorithm like k-d trees, or limit the search to a spatial radius instead of checking all particles.

STYLE Particle and Field classes

The color values (hue 180, 190, 320) are hardcoded throughout the sketch, making it hard to create consistent color themes.

๐Ÿ’ก Define color constants at the top: const ATTRACTOR_HUE = 190; const REPULSOR_HUE = 320; const SLOW_PARTICLE_HUE = 180; const FAST_PARTICLE_HUE = 320; Then use these constants everywhere.

FEATURE Field class

Fields have fixed strength, but it would be interesting to allow users to adjust field strength interactively.

๐Ÿ’ก Add scroll wheel support to modify field strength: detect mouseWheel() and iterate through fields array to adjust the strength of the nearest field.

BUG mouseReleased()

If the user clicks very quickly multiple times in the same location, it creates multiple overlapping wells with identical properties, wasting memory.

๐Ÿ’ก Check if a well already exists near the click position and increase its strength instead of creating a new one.

PERFORMANCE draw()

Every frame, the background() function with alpha 18 creates a semi-transparent overlay. With 60 FPS, this is recalculated constantly even when nothing changes.

๐Ÿ’ก Consider using a separate graphics buffer (createGraphics) to handle the fading effect more efficiently, or adjust the alpha value based on particle density.

FEATURE Particle class

Particles wrap around screen edges instantly, which can look jarring. There's no visual continuity.

๐Ÿ’ก Add optional toroidal wrapping visualization: draw particles that are wrapping as semi-transparent at the opposite edge to show continuity.

STYLE keyPressed()

The keyboard controls are case-insensitive for some keys (C, R, H) but not others (1, 2, ENTER), which is inconsistent.

๐Ÿ’ก Add comments explaining why some keys are case-insensitive (they're letters) and others aren't (they're numbers/special keys). Or add uppercase handling for 1 and 2 if shift is pressed.

Preview

AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we - Code flow showing setup, draw, windowresized, initparticles, handlecollisions, togglecrystallize, buildcrystalgeometry, drawcrystals, analyzeflow, generatesymmetrysuggestions, generatechaossuggestions, applysuggestions, drawsuggestions, drawhud, mousepressed, mousereleased, mousedragged, keypressed, particle, field
Code Flow Diagram