// Differential Growth
//
// A closed polyline whose nodes:
// - repel other nearby nodes (keeps them from crowding)
// - attract toward the midpoint of their two immediate neighbors (spring force)
// - subdivide when an edge grows too long (inserts a midpoint node)
//
// Result: organic crinkly contours like coral, lichen, or growing tissue.
// Anders Hoff's hyphae aesthetic.
export const WARMUP = { framesBeforeReady: 600 };
export const PARAMS = {
repulsion: { value: 18, min: 4, max: 60, step: 0.5, label: "Repulsion Radius", folder: "Behavior" },
maxEdge: { value: 14, min: 4, max: 40, step: 0.5, label: "Max Edge Length", folder: "Behavior" },
step: { value: 0.6, min: 0.1, max: 2.0, step: 0.05, label: "Step Size", folder: "Behavior" },
attraction: { value: 0.12, min: 0, max: 0.5, step: 0.005, label: "Neighbor Attraction", folder: "Behavior" },
maxNodes: { value: 2500, min: 100, max: 6000, step: 50, label: "Max Nodes", folder: "Structure" },
stroke: { value: "#d97757", type: "color", label: "Stroke", folder: "Appearance" },
bg: { value: "#0a0a0a", type: "color", label: "Background", folder: "Appearance" },
strokeW: { value: 1.5, min: 0.5, max: 4, step: 0.1, label: "Stroke Width", folder: "Appearance" },
};
export const SHARE = {
bookmarked: ["repulsion", "maxEdge", "step", "attraction", "maxNodes", "stroke", "bg", "strokeW"],
};
let state;
// ── Spatial hash grid for O(1) neighbor lookup ──────────────────────────────
class SpatialGrid {
constructor(cellSize) {
this.cellSize = cellSize;
this.cells = new Map();
}
_key(x, y) {
const cx = Math.floor(x / this.cellSize);
const cy = Math.floor(y / this.cellSize);
return `${cx},${cy}`;
}
clear() {
this.cells.clear();
}
insert(node) {
const key = this._key(node.x, node.y);
let cell = this.cells.get(key);
if (!cell) { cell = []; this.cells.set(key, cell); }
cell.push(node);
}
query(x, y, radius) {
const r = radius;
const cs = this.cellSize;
const minCx = Math.floor((x - r) / cs);
const maxCx = Math.floor((x + r) / cs);
const minCy = Math.floor((y - r) / cs);
const maxCy = Math.floor((y + r) / cs);
const result = [];
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const cell = this.cells.get(`${cx},${cy}`);
if (cell) {
for (const node of cell) result.push(node);
}
}
}
return result;
}
}
// ── Initialization ───────────────────────────────────────────────────────────
function buildInitialCircle(cx, cy, radius, count) {
const nodes = [];
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
nodes.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
}
return nodes;
}
export function sketchSetup(ctx, w, h, tng, params) {
const nodes = buildInitialCircle(w / 2, h / 2, 30, 12);
const grid = new SpatialGrid(params.repulsion * 2);
state = { nodes, grid, w, h };
return state;
}
// ── Per-frame growth step ────────────────────────────────────────────────────
function growthStep(nodes, grid, params) {
const n = nodes.length;
const repR = params.repulsion;
const repR2 = repR * repR;
const stepSz = params.step;
const attract = params.attraction;
// Rebuild spatial grid
grid.cellSize = repR * 2;
grid.clear();
for (const node of nodes) grid.insert(node);
// Compute forces and displace
for (let i = 0; i < n; i++) {
const node = nodes[i];
const prev = nodes[(i - 1 + n) % n];
const next = nodes[(i + 1) % n];
// Repulsion: push away from all nearby nodes
let rx = 0, ry = 0;
const neighbors = grid.query(node.x, node.y, repR);
for (const other of neighbors) {
if (other === node) continue;
const dx = node.x - other.x;
const dy = node.y - other.y;
const d2 = dx * dx + dy * dy;
if (d2 < repR2 && d2 > 0) {
const d = Math.sqrt(d2);
rx += (dx / d) * (1 - d / repR);
ry += (dy / d) * (1 - d / repR);
}
}
// Attraction: pull toward midpoint of neighbors (spring toward neighbors)
const midX = (prev.x + next.x) * 0.5;
const midY = (prev.y + next.y) * 0.5;
const ax = (midX - node.x) * attract;
const ay = (midY - node.y) * attract;
node.x += (rx + ax) * stepSz;
node.y += (ry + ay) * stepSz;
}
}
// Insert midpoints where edges are too long
function subdivide(nodes, maxEdge) {
const result = [];
const n = nodes.length;
for (let i = 0; i < n; i++) {
const a = nodes[i];
const b = nodes[(i + 1) % n];
result.push(a);
const dx = b.x - a.x;
const dy = b.y - a.y;
if (dx * dx + dy * dy > maxEdge * maxEdge) {
result.push({ x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5 });
}
}
return result;
}
// ── Drawing ──────────────────────────────────────────────────────────────────
export function sketchDraw(ctx, w, h, params, tng) {
const { nodes, grid } = state;
// Growth step each frame
if (nodes.length < params.maxNodes) {
growthStep(nodes, grid, params);
const grown = subdivide(nodes, params.maxEdge);
// Cap at maxNodes
if (grown.length <= params.maxNodes) {
state.nodes.length = 0;
for (const n of grown) state.nodes.push(n);
} else {
// Only accept subdivisions up to the cap
let added = 0;
for (let i = 0; i < grown.length && state.nodes.length < params.maxNodes; i++) {
state.nodes[i] = grown[i];
added++;
}
state.nodes.length = added;
}
} else {
// Still run displacement even at max nodes (no subdivision)
growthStep(nodes, grid, params);
}
// Draw background
ctx.fillStyle = params.bg;
ctx.fillRect(0, 0, w, h);
// Draw closed polyline
const pts = state.nodes;
if (pts.length < 2) return;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.closePath();
ctx.strokeStyle = params.stroke;
ctx.lineWidth = params.strokeW;
ctx.stroke();
}