Fields of Attraction

This generative art program generates one-off abstract compositions built from overlapping polygons, irregular circles, and transformed repetitions. Each image combines a randomly selected geometric layout, colour palette, textural gradient treatment, and a higher-level compositional event that gives the result a distinct visual centre or directional logic.

How it works

On every reroll, the sketch:

Selects a node layout, such as a scattered field, grid, vertical paths, concentric rings, or a donut-like structure.

Builds polygonal segments by connecting nearby nodes while limiting overly sharp angles and controlling intersections.

Optionally adds smooth or wobbly circular forms.

Duplicates selected shapes through local rotation, rotation around a focal point, mirroring, expansion, and—when the image is too sparse—a further coverage-driven rotation pass.

Applies a chosen palette as softly graded, lightly pixel-textured fills, with optional multiply or screen blending.

Adds either clean outlines, layered sketch-like outlines, or no outlines at all.

Compositional logic

Rather than treating every shape as independent, the program now gives each image a single organising event:

A quiet void creates charged negative space around which forms accumulate.

A dense knot concentrates shapes and brighter accents around a focal point.

A diagonal current steers the composition along a directional flow.

An orbit draws forms and colour emphasis toward a circular path.

A fracture line introduces a split or seam that separates and activates the image.

These events influence node placement, shape selection, duplication, gradient direction, and colour emphasis, helping each result feel more composed and less uniformly random.

Visual character

The output sits between geometric abstraction, diagrammatic drawing, collage, and printmaking. Images can resemble translucent paper cut-outs, screenprints, architectural plans, orbital diagrams, botanical forms, or loose painted constructions.

Colour is responsive to the structure: shapes near the main event, around an orbit, or along a fracture receive stronger accent colour or subtle brightness shifts. Repeated forms act like echoes or layers, creating rhythm, depth, and occasional visual tension, while the restrained shader grain gives the surfaces a tactile, imperfect finish.

/* =========================================================================
GENERATIVE STRUCTURE STUDY — p5.js
-------------------------------------------------------------------------
Adds persistent, composition-aware colour roles to every segment:
- Originals stay predominantly in the palette's colorA -> colorB family.
- Expand copies progressively favour colorC.
- Mirrored copies reverse the gradient direction.
- localRotate / globalRotate and coverage-driven extraRotate copies get
related but distinct role treatments.
- Role selection combines centroid distance from a persistent focal point,
polygon area/compactness, origin, and local density.

Press "r" to reroll a brand-new composition with fresh random settings.
Press "s" to save a PNG.
========================================================================= */

const CANVAS_SIZE = 900;
const NOISE_SCALE = 0.55;

let SETTINGS = {};
let nodes = [];
let segments = [];
let extraSegments = [];
let extraRotateSegments = [];
let PALETTE;
let noiseShader;
let seedTex;
let shaderBuf;
let coverageBuf;

const PALETTES = {
Peachy: {
colorA: [1.00, 0.85, 0.78], colorB: [0.98, 0.55, 0.45], colorC: [1.0, 0.95, 0.7],
noiseIntensity: 0.10, background: [255, 244, 236], blend: null
},
'Hi-light': {
colorA: [0.05, 0.05, 0.08], colorB: [0.1, 0.95, 0.85], colorC: [0.95, 0.1, 0.85],
noiseIntensity: 0.32, background: [8, 8, 12], blend: 'screen'
},
Trove: {
colorA: [0.25, 0.15, 0.35], colorB: [0.05, 0.35, 0.4], colorC: [0.8, 0.6, 0.2],
noiseIntensity: 0.26, background: [30, 20, 40], blend: 'multiply', bgGradient: true
},
'Fruit Salad': {
colorA: [0.95, 0.75, 0.15], colorB: [0.85, 0.2, 0.35], colorC: [0.25, 0.65, 0.35],
noiseIntensity: 0.28, background: [250, 240, 200], blend: 'multiply', bgGradient: true
},
'Moss & Clay': {
colorA: [0.55, 0.62, 0.42], colorB: [0.35, 0.45, 0.28], colorC: [0.72, 0.48, 0.32],
noiseIntensity: 0.14, background: [236, 231, 214], blend: null
},
'Ink Wash': {
colorA: [0.85, 0.87, 0.9], colorB: [0.25, 0.3, 0.38], colorC: [0.5, 0.55, 0.62],
noiseIntensity: 0.08, background: [242, 243, 246], blend: 'multiply'
},
'Sundown Coast': {
colorA: [1.0, 0.7, 0.45], colorB: [0.15, 0.45, 0.5], colorC: [0.95, 0.85, 0.35],
noiseIntensity: 0.16, background: [255, 235, 214], blend: null, bgGradient: true
},
Frost: {
colorA: [0.95, 0.97, 1.0], colorB: [0.75, 0.85, 0.95], colorC: [0.6, 0.7, 0.85],
noiseIntensity: 0.05, background: [246, 249, 252], blend: null
},
Ember: {
colorA: [0.12, 0.05, 0.05], colorB: [0.75, 0.2, 0.08], colorC: [0.95, 0.55, 0.15],
noiseIntensity: 0.22, background: [18, 10, 10], blend: 'screen'
},
Botanical: {
colorA: [0.9, 0.92, 0.82], colorB: [0.22, 0.4, 0.28], colorC: [0.55, 0.65, 0.35],
noiseIntensity: 0.12, background: [244, 246, 236], blend: 'multiply'
},
'Nordic Slate': {
colorA: [0.72, 0.75, 0.8], colorB: [0.3, 0.36, 0.44], colorC: [0.85, 0.7, 0.35],
noiseIntensity: 0.11, background: [228, 231, 235], blend: null
},
'Citrus Pop': {
colorA: [1.0, 0.95, 0.3], colorB: [0.75, 0.9, 0.2], colorC: [1.0, 0.55, 0.15],
noiseIntensity: 0.2, background: [255, 250, 220], blend: 'screen'
},
'Velvet Plum': {
colorA: [0.3, 0.1, 0.32], colorB: [0.55, 0.15, 0.45], colorC: [0.85, 0.7, 0.25],
noiseIntensity: 0.2, background: [26, 12, 28], blend: 'multiply', bgGradient: true
},
'Paper & Ash': {
colorA: [0.97, 0.95, 0.9], colorB: [0.55, 0.5, 0.5], colorC: [0.8, 0.65, 0.62],
noiseIntensity: 0.06, background: [248, 245, 240], blend: null
}
};

const LAYOUTS = [
'scattered-even', 'scattered-centered', 'scattered-vertical',
'grid', 'vertical-lines', 'vertical-path',
'ring-circle', 'ring-donut', 'ring-multi'
];
const EXTRA_METHODS = ['localRotate', 'globalRotate', 'mirror', 'expand'];

const vertSrc = `
precision highp float;
attribute vec3 aPosition;
attribute vec2 aTexCoord;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying vec2 vTexCoord;
void main() {
vTexCoord = aTexCoord;
vec4 positionVec4 = vec4(aPosition, 1.0);
gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;
}
`;

const fragSrc = `
precision highp float;
varying vec2 vTexCoord;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uColorC;
uniform float uAngle;
uniform float uNoiseIntensity;
uniform float uPixelSize;
uniform float uEdgeFade;
uniform sampler2D uSeedTex;

void main() {
vec2 uv = vTexCoord;
vec2 dir = vec2(cos(uAngle), sin(uAngle));
float g = dot(uv - 0.5, dir) + 0.5;
g = clamp(g, 0.0, 1.0);
vec3 base = mix(uColorA, uColorB, g);

vec2 pUV = floor(uv / uPixelSize) * uPixelSize + uPixelSize * 0.5;
vec3 seed = texture2D(uSeedTex, pUV).rgb;
vec3 noiseColor = mix(uColorB, uColorC, seed.r);
noiseColor = mix(noiseColor, seed, 0.35 + 0.3 * seed.b);

float noiseMix = uNoiseIntensity * uEdgeFade * 0.8;
vec3 col = mix(base, noiseColor, noiseMix);
gl_FragColor = vec4(col, 1.0);
}
`;

function preload() {}

function setup() {
createCanvas(CANVAS_SIZE, CANVAS_SIZE);
pixelDensity(1);
shaderBuf = createGraphics(CANVAS_SIZE, CANVAS_SIZE, WEBGL);
noiseShader = shaderBuf.createShader(vertSrc, fragSrc);
coverageBuf = createGraphics(160, 160);
buildSeedTexture();
rerollAndDraw();
}

function keyPressed() {
if (key === 'r' || key === 'R') rerollAndDraw();
if (key === 's' || key === 'S') saveCanvas('structure-study', 'png');
}

function buildSeedTexture() {
seedTex = createGraphics(64, 64);
seedTex.loadPixels();
for (let i = 0; i < seedTex.pixels.length; i += 4) {
seedTex.pixels[i] = random(0, 255);
seedTex.pixels[i + 1] = random(0, 255);
seedTex.pixels[i + 2] = random(0, 255);
seedTex.pixels[i + 3] = 255;
}
seedTex.updatePixels();
}

function pickSettings() {
const layout = random(LAYOUTS);
const roundedChoices = [0, 0.25, 0.5, 0.8, 1.0];
const outlineModeChoices = ['stroke', 'sketchy', 'none'];
const sketchyStyles = ['boldFew', 'thinMany'];
const numExtraMethods = random() < 0.5 ? 1 : 2;
const extraMethods = shuffle(EXTRA_METHODS.slice()).slice(0, numExtraMethods);
const outlineMode = random(outlineModeChoices);

return {
layout,
paletteName: random(Object.keys(PALETTES)),
numSegments: floor(random(14, 34)),
nodesPerSegmentRange: [3, floor(random(5, 9))],
targetSizeSets: [[0.08, 0.18], [0.18, 0.35], [0.35, 0.6]],
maxIntersections: floor(random(0, 6)),
roundedChance: random(roundedChoices),
outlineMode,
sketchyStyle: outlineMode === 'sketchy' ? random(sketchyStyles) : null,
numCircles: floor(random(0, 3)),
extraMethods,
coverageThreshold: layoutIsStructured(layout) ? random(0.30, 0.42) : random(0.42, 0.58),
extraRotateStrength: random() < 0.5 ? 'subtle' : 'dramatic',
colorFocal: {
x: random(CANVAS_SIZE * 0.22, CANVAS_SIZE * 0.78),
y: random(CANVAS_SIZE * 0.22, CANVAS_SIZE * 0.78)
}
};
}

function layoutIsStructured(layout) {
return ['grid', 'ring-circle', 'ring-donut', 'ring-multi'].includes(layout);
}

function rerollAndDraw() {
randomSeed(Date.now() % 100000);
noiseSeed(random(100000));
SETTINGS = pickSettings();
PALETTE = PALETTES[SETTINGS.paletteName];
nodes = buildLayout(SETTINGS.layout, CANVAS_SIZE, CANVAS_SIZE);
segments = generateSegments(nodes, SETTINGS);
addCircleSegments(segments, SETTINGS);
extraSegments = buildExtras(segments, SETTINGS);
extraRotateSegments = maybeBuildExtraRotate(segments, extraSegments, SETTINGS);
assignColourRoles(segments.concat(extraSegments, extraRotateSegments), SETTINGS);
renderComposition();
console.log('SETTINGS', SETTINGS, 'PALETTE', SETTINGS.paletteName);
}

function buildLayout(type, w, h) {
const pts = [];
const margin = w * 0.06;

if (type === 'scattered-even') {
const n = floor(random(40, 90));
for (let i = 0; i < n; i++) pts.push(mkNode(random(margin, w - margin), random(margin, h - margin)));
} else if (type === 'scattered-centered') {
const n = floor(random(40, 90));
const cx = w / 2, cy = h / 2;
for (let i = 0; i < n; i++) {
const x = constrain(randomGaussian(cx, w * 0.16), margin, w - margin);
const y = constrain(randomGaussian(cy, h * 0.16), margin, h - margin);
pts.push(mkNode(x, y));
}
} else if (type === 'scattered-vertical') {
const n = floor(random(40, 90));
for (let i = 0; i < n; i++) {
const x = random(margin, w - margin);
const y = margin + pow(random(), 0.45) * (h - 2 * margin);
pts.push(mkNode(x, y));
}
} else if (type === 'grid') {
const cols = floor(random([3, 4, 5, 6, 8]));
const rows = floor(random([3, 4, 5, 6, 8]));
const jitter = min(w, h) / (cols * 4);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = map(i, 0, cols - 1, margin, w - margin) + random(-jitter, jitter);
const y = map(j, 0, rows - 1, margin, h - margin) + random(-jitter, jitter);
pts.push(mkNode(x, y));
}
}
} else if (type === 'vertical-lines') {
const lineCount = floor(random(3, 9));
const nodesPerLine = floor(random(5, 12));
const jitter = w / (lineCount * 3);
for (let i = 0; i < lineCount; i++) {
const baseX = map(i, 0, lineCount - 1, margin, w - margin);
for (let j = 0; j < nodesPerLine; j++) {
const y = map(j, 0, nodesPerLine - 1, margin, h - margin);
pts.push(mkNode(baseX + random(-jitter, jitter), y + random(-jitter, jitter)));
}
}
} else if (type === 'vertical-path') {
const pathCount = floor(random(1, 4));
const stepsPerPath = floor(random(10, 22));
for (let p = 0; p < pathCount; p++) {
let x = random(margin, w - margin);
for (let s = 0; s < stepsPerPath; s++) {
const y = map(s, 0, stepsPerPath - 1, margin, h - margin);
x = constrain(x + random(-w * 0.05, w * 0.05), margin, w - margin);
pts.push(mkNode(x, y));
}
}
} else if (type === 'ring-circle') {
const n = floor(random(10, 24));
const r = min(w, h) * random(0.28, 0.4);
for (let i = 0; i < n; i++) {
const a = map(i, 0, n, 0, TWO_PI);
pts.push(mkNode(w / 2 + cos(a) * r, h / 2 + sin(a) * r));
}
} else if (type === 'ring-donut') {
const rOuter = min(w, h) * random(0.32, 0.42);
const rInner = rOuter * random(0.35, 0.6);
const nOuter = floor(random(10, 20));
const nInner = floor(random(8, 16));
for (let i = 0; i < nOuter; i++) {
const a = map(i, 0, nOuter, 0, TWO_PI);
pts.push(mkNode(w / 2 + cos(a) * rOuter, h / 2 + sin(a) * rOuter));
}
for (let i = 0; i < nInner; i++) {
const a = map(i, 0, nInner, 0, TWO_PI) + 0.3;
pts.push(mkNode(w / 2 + cos(a) * rInner, h / 2 + sin(a) * rInner));
}
} else if (type === 'ring-multi') {
const ringCount = floor(random(3, 6));
for (let r = 0; r < ringCount; r++) {
const radius = min(w, h) * map(r, 0, ringCount - 1, 0.12, 0.46);
const cx = w / 2 + random(-w * 0.08, w * 0.08);
const cy = h / 2 + random(-h * 0.08, h * 0.08);
const n = floor(random(6, 16));
for (let i = 0; i < n; i++) {
const a = map(i, 0, n, 0, TWO_PI) + random(0, 0.5);
pts.push(mkNode(cx + cos(a) * radius, cy + sin(a) * radius));
}
}
}
return pts;
}

function mkNode(x, y) {
return { x, y, used: 0 };
}

function chooseStarterNode(nds) {
const weights = nds.map(n => 1 / (1 + n.used));
const total = weights.reduce((a, b) => a + b, 0);
let r = random(total);
let acc = 0;
for (let i = 0; i < nds.length; i++) {
acc += weights[i];
if (r <= acc) return nds[i];
}
return nds[nds.length - 1];
}

function pickTargetSize(settings) {
const diag = sqrt(CANVAS_SIZE * CANVAS_SIZE * 2);
const range = random(settings.targetSizeSets);
return random(range[0], range[1]) * diag;
}

function buildSegmentNodes(allNodes, starter, targetSize, countRange) {
let others = allNodes.filter(n => n !== starter);
others.sort((a, b) => dist(starter.x, starter.y, a.x, a.y) - dist(starter.x, starter.y, b.x, b.y));

let count = floor(random(countRange[0], countRange[1] + 1));
count = min(count, others.length);
if (count < 2) return [starter];

let anchorIdx = 0;
let bestDiff = Infinity;
for (let i = 0; i < others.length; i++) {
const d = dist(starter.x, starter.y, others[i].x, others[i].y);
const diff = abs(d - targetSize);
if (diff < bestDiff) {
bestDiff = diff;
anchorIdx = i;
}
}

const windowLo = max(0, anchorIdx - count * 2);
const windowHi = min(others.length, anchorIdx + count * 2);
let pool = others.slice(windowLo, windowHi);
const chosen = [starter];
pool = shuffle(pool);
for (let i = 0; i < count - 1 && i < pool.length; i++) chosen.push(pool[i]);

const cx = chosen.reduce((a, n) => a + n.x, 0) / chosen.length;
const cy = chosen.reduce((a, n) => a + n.y, 0) / chosen.length;
chosen.sort((a, b) => atan2(a.y - cy, a.x - cx) - atan2(b.y - cy, b.x - cx));
return chosen;
}

function minInteriorAngle(poly) {
if (poly.length < 3) return PI;
let minAngle = PI;
for (let i = 0; i < poly.length; i++) {
const p0 = poly[(i - 1 + poly.length) % poly.length];
const p1 = poly[i];
const p2 = poly[(i + 1) % poly.length];
const v1 = createVector(p0.x - p1.x, p0.y - p1.y);
const v2 = createVector(p2.x - p1.x, p2.y - p1.y);
minAngle = min(minAngle, abs(v1.angleBetween(v2)));
}
return minAngle;
}

function segmentEdges(poly) {
const edges = [];
for (let i = 0; i < poly.length; i++) edges.push([poly[i], poly[(i + 1) % poly.length]]);
return edges;
}

function linesIntersect(p1, p2, p3, p4) {
const d1x = p2.x - p1.x, d1y = p2.y - p1.y;
const d2x = p4.x - p3.x, d2y = p4.y - p3.y;
const denom = d1x * d2y - d1y * d2x;
if (abs(denom) < 1e-9) return false;
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom;
const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denom;
return t > 0.02 && t < 0.98 && u > 0.02 && u < 0.98;
}

function countIntersections(poly, existingSegments) {
const edgesA = segmentEdges(poly);
let count = 0;
for (const seg of existingSegments) {
const edgesB = segmentEdges(seg.nodes);
for (const ea of edgesA) {
for (const eb of edgesB) {
if (linesIntersect(ea[0], ea[1], eb[0], eb[1])) count++;
}
}
}
return count;
}

function generateSegments(allNodes, settings) {
const result = [];
let attempts = 0;
const maxAttempts = settings.numSegments * 20;
const MIN_ANGLE = radians(18);

while (result.length < settings.numSegments && attempts < maxAttempts) {
attempts++;
if (allNodes.length < 3) break;
const starter = chooseStarterNode(allNodes);
const targetSize = pickTargetSize(settings);
const poly = buildSegmentNodes(allNodes, starter, targetSize, settings.nodesPerSegmentRange);
if (poly.length < 3 || minInteriorAngle(poly) < MIN_ANGLE) continue;
if (countIntersections(poly, result) > settings.maxIntersections) continue;
poly.forEach(n => n.used++);
result.push(makeSegmentObject(poly, settings, false));
}
return result;
}

function polygonArea(poly) {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
sum += a.x * b.y - b.x * a.y;
}
return abs(sum) * 0.5;
}

function polygonPerimeter(poly) {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
sum += dist(a.x, a.y, b.x, b.y);
}
return sum;
}

function segmentMetrics(poly) {
const cx = poly.reduce((sum, n) => sum + n.x, 0) / poly.length;
const cy = poly.reduce((sum, n) => sum + n.y, 0) / poly.length;
const area = polygonArea(poly);
const perimeter = polygonPerimeter(poly);
const compactness = perimeter > 0 ? constrain((4 * PI * area) / (perimeter * perimeter), 0, 1) : 0;
return { cx, cy, area, perimeter, compactness };
}

function makeSegmentObject(poly, settings, isCircle) {
const rounded = random() < settings.roundedChance;
let hasStroke = false;
let sketchy = null;
if (settings.outlineMode === 'stroke') hasStroke = random() < 0.7;
else if (settings.outlineMode === 'sketchy') sketchy = settings.sketchyStyle;

const xs = poly.map(n => n.x);
const ys = poly.map(n => n.y);
const pad = 6;
const metrics = segmentMetrics(poly);

return {
nodes: poly,
isCircle,
rounded,
hasStroke,
sketchy,
bbox: {
x: min(xs) - pad,
y: min(ys) - pad,
w: max(xs) - min(xs) + pad * 2,
h: max(ys) - min(ys) + pad * 2
},
origin: 'original',
metrics,
colourRole: null,
colourProfile: null
};
}

function addCircleSegments(segList, settings) {
for (let c = 0; c < settings.numCircles; c++) {
const smooth = random() < 0.5;
const nPoints = smooth ? floor(random(40, 70)) : floor(random(14, 24));
const wibbleAmp = smooth ? random(2, 6) : random(14, 34);
const r = CANVAS_SIZE * random(0.08, 0.28);
const cx = random(CANVAS_SIZE * 0.2, CANVAS_SIZE * 0.8);
const cy = random(CANVAS_SIZE * 0.2, CANVAS_SIZE * 0.8);
const noiseOffset = random(1000);
const poly = [];
for (let i = 0; i < nPoints; i++) {
const a = map(i, 0, nPoints, 0, TWO_PI);
const wob = (noise(cos(a) * 0.6 + noiseOffset, sin(a) * 0.6 + noiseOffset) - 0.5) * 2 * wibbleAmp;
poly.push(mkNode(cx + cos(a) * (r + wob), cy + sin(a) * (r + wob)));
}
segList.push(makeSegmentObject(poly, settings, true));
}
}

function transformCopy(seg, transformFn) {
const newNodes = seg.nodes.map(n => {
const p = transformFn(n.x, n.y);
return { x: p.x, y: p.y, used: n.used };
});
const clone = makeSegmentObject(newNodes, SETTINGS, seg.isCircle);
clone.hasStroke = seg.hasStroke;
clone.sketchy = seg.sketchy;
clone.rounded = seg.rounded;
return clone;
}

function applyLocalRotate(seg) {
const { cx, cy } = seg.metrics;
const ang = random(-PI * 0.5, PI * 0.5);
return transformCopy(seg, (x, y) => {
const dx = x - cx, dy = y - cy;
return {
x: cx + dx * cos(ang) - dy * sin(ang),
y: cy + dx * sin(ang) + dy * cos(ang)
};
});
}

function applyGlobalRotate(seg, focal) {
const ang = random(-PI * 0.35, PI * 0.35);
return transformCopy(seg, (x, y) => {
const dx = x - focal.x, dy = y - focal.y;
return {
x: focal.x + dx * cos(ang) - dy * sin(ang),
y: focal.y + dx * sin(ang) + dy * cos(ang)
};
});
}

function applyMirror(seg) {
const { cx, cy } = seg.metrics;
const useEdgePoint = random() < 0.5;
const anchor = useEdgePoint ? seg.nodes[floor(random(seg.nodes.length))] : { x: cx, y: cy };
const ang = random(TWO_PI);
const nx = cos(ang), ny = sin(ang);
return transformCopy(seg, (x, y) => {
const dx = x - anchor.x, dy = y - anchor.y;
const projection = dx * nx + dy * ny;
return { x: anchor.x + 2 * projection * nx - dx, y: anchor.y + 2 * projection * ny - dy };
});
}

function applyExpand(seg, focal, factor) {
return transformCopy(seg, (x, y) => {
const dx = x - focal.x, dy = y - focal.y;
return { x: focal.x + dx * factor, y: focal.y + dy * factor };
});
}

function buildExtras(segList, settings) {
const extras = [];
settings.extraMethods.forEach(method => {
const focal = { x: random(CANVAS_SIZE), y: random(CANVAS_SIZE) };
segList.forEach(seg => {
const { cx, cy } = seg.metrics;
const d = dist(cx, cy, focal.x, focal.y);
const norm = constrain(1 - d / (CANVAS_SIZE * 0.75), 0, 1);
const chance = 0.15 + norm * 0.65;
if (random() >= chance) return;

let clone;
if (method === 'localRotate') clone = applyLocalRotate(seg);
else if (method === 'globalRotate') clone = applyGlobalRotate(seg, focal);
else if (method === 'mirror') clone = applyMirror(seg);
else clone = applyExpand(seg, focal, random(1.15, 1.6));
clone.origin = method;
extras.push(clone);
});
});
return extras;
}

function measureCoverage(allSegs) {
coverageBuf.clear();
coverageBuf.push();
coverageBuf.scale(coverageBuf.width / CANVAS_SIZE);
coverageBuf.noStroke();
coverageBuf.fill(0);
allSegs.forEach(seg => drawPolyShape(coverageBuf, seg));
coverageBuf.pop();
coverageBuf.loadPixels();
let filled = 0;
const total = coverageBuf.pixels.length / 4;
for (let i = 3; i < coverageBuf.pixels.length; i += 4) {
if (coverageBuf.pixels[i] > 10) filled++;
}
return filled / total;
}

function maybeBuildExtraRotate(segList, extras, settings) {
const combined = segList.concat(extras);
if (measureCoverage(combined) >= settings.coverageThreshold) return [];

const focal = { x: random(CANVAS_SIZE), y: random(CANVAS_SIZE) };
const strong = settings.extraRotateStrength === 'dramatic';
const factor = strong ? random(1.8, 2.6) : random(1.1, 1.4);
const angleRange = strong ? PI * 0.8 : PI * 0.25;
const pool = shuffle(combined.slice());
const take = strong ? pool.length : floor(pool.length * 0.4);
const out = [];

for (let i = 0; i < take; i++) {
const seg = pool[i];
const ang = random(-angleRange, angleRange);
const clone = transformCopy(seg, (x, y) => {
let dx = (x - focal.x) * factor;
let dy = (y - focal.y) * factor;
const rx = dx * cos(ang) - dy * sin(ang);
const ry = dx * sin(ang) + dy * cos(ang);
return { x: focal.x + rx, y: focal.y + ry };
});
clone.origin = 'extraRotate';
out.push(clone);
}
return out;
}

function mixRGB(a, b, amount) {
const t = constrain(amount, 0, 1);
return [
lerp(a[0], b[0], t),
lerp(a[1], b[1], t),
lerp(a[2], b[2], t)
];
}

function darkenRGB(rgb, amount) {
return mixRGB(rgb, [0, 0, 0], amount);
}

function desaturateRGB(rgb, amount) {
const grey = (rgb[0] + rgb[1] + rgb[2]) / 3;
return mixRGB(rgb, [grey, grey, grey], amount);
}

function getDensity(seg, allSegs) {
const { cx, cy } = seg.metrics;
const radius = CANVAS_SIZE * 0.24;
let score = 0;
for (const other of allSegs) {
if (other === seg) continue;
const d = dist(cx, cy, other.metrics.cx, other.metrics.cy);
if (d < radius) score += 1 - d / radius;
}
return constrain(score / 5.0, 0, 1);
}

function colourRoleForSegment(seg, allSegs, settings) {
const m = seg.metrics;
const focalDistance = constrain(dist(m.cx, m.cy, settings.colorFocal.x, settings.colorFocal.y) / (CANVAS_SIZE * 0.72), 0, 1);
const density = getDensity(seg, allSegs);
const relativeArea = constrain(m.area / (CANVAS_SIZE * CANVAS_SIZE * 0.16), 0, 1);
const irregularity = 1 - m.compactness;

if (seg.origin === 'expand') return 'accent';
if (seg.origin === 'mirror') return 'mirror';
if (seg.origin === 'extraRotate') return density > 0.50 ? 'shadow' : 'accent';
if (seg.origin === 'localRotate') return focalDistance < 0.45 ? 'highlight' : 'base';
if (seg.origin === 'globalRotate') return focalDistance < 0.55 ? 'accent' : 'shadow';
if (density > 0.57) return 'shadow';
if (focalDistance < 0.25 && (relativeArea > 0.24 || irregularity > 0.42)) return 'highlight';
if (relativeArea > 0.67 && focalDistance > 0.42) return 'accent';
return 'base';
}

function colourProfileForSegment(seg, role, allSegs, settings) {
const density = getDensity(seg, allSegs);
const m = seg.metrics;
const focalDistance = constrain(dist(m.cx, m.cy, settings.colorFocal.x, settings.colorFocal.y) / (CANVAS_SIZE * 0.72), 0, 1);
let a = PALETTE.colorA.slice();
let b = PALETTE.colorB.slice();
let c = PALETTE.colorC.slice();
let angle = random(TWO_PI);
let noise = PALETTE.noiseIntensity * NOISE_SCALE;

if (role === 'accent') {
a = mixRGB(a, c, seg.origin === 'expand' ? 0.48 + focalDistance * 0.24 : 0.34);
b = mixRGB(b, c, seg.origin === 'expand' ? 0.76 : 0.58);
c = mixRGB(c, b, 0.16);
noise *= 1.16;
} else if (role === 'shadow') {
a = desaturateRGB(darkenRGB(a, 0.20 + density * 0.10), 0.14 + density * 0.16);
b = desaturateRGB(darkenRGB(b, 0.28 + density * 0.14), 0.14 + density * 0.16);
c = desaturateRGB(darkenRGB(c, 0.20), 0.20);
noise *= 0.72;
} else if (role === 'highlight') {
a = mixRGB(a, [1, 1, 1], 0.22);
b = mixRGB(b, c, 0.20);
c = mixRGB(c, [1, 1, 1], 0.12);
noise *= 0.90;
} else if (role === 'mirror') {
const oldA = a;
a = mixRGB(b, c, 0.12);
b = mixRGB(oldA, c, 0.10);
c = mixRGB(c, oldA, 0.18);
angle += PI;
noise *= 0.94;
} else {
a = mixRGB(a, b, density * 0.08);
b = mixRGB(b, c, focalDistance * 0.08);
}

if (seg.origin === 'expand') angle = atan2(m.cy - settings.colorFocal.y, m.cx - settings.colorFocal.x);
if (seg.origin === 'globalRotate' || seg.origin === 'extraRotate') angle = atan2(m.cy - settings.colorFocal.y, m.cx - settings.colorFocal.x) + HALF_PI;
if (seg.origin === 'mirror') angle += PI;

return { a, b, c, angle, noise };
}

function assignColourRoles(allSegs, settings) {
allSegs.forEach(seg => {
seg.metrics = segmentMetrics(seg.nodes);
});
allSegs.forEach(seg => {
seg.colourRole = colourRoleForSegment(seg, allSegs, settings);
seg.colourProfile = colourProfileForSegment(seg, seg.colourRole, allSegs, settings);
});
}

function noiseParamsForCanvas(dim) {
const boundaries = [250, 450, 700];
const sizes = [1, 2, 3, 4];
let idx = 0;
boundaries.forEach(b => {
if (dim >= b) idx++;
});
const pixelSizePx = sizes[idx];
let nearest = Infinity;
boundaries.forEach(b => {
nearest = min(nearest, abs(dim - b));
});
const fadeZone = 40;
const edgeFade = nearest < fadeZone ? map(nearest, 0, fadeZone, 0.35, 1.0) : 1.0;
return { pixelSizePx, edgeFade };
}

function drawPolyShape(g, seg) {
const poly = seg.nodes;
if (seg.rounded && poly.length >= 3) {
drawRoundedPoly(g, poly);
} else {
g.beginShape();
poly.forEach(n => g.vertex(n.x, n.y));
g.endShape(CLOSE);
}
}

function drawRoundedPoly(g, poly) {
const radiusFrac = 0.22;
g.beginShape();
for (let i = 0; i < poly.length; i++) {
const prev = poly[(i - 1 + poly.length) % poly.length];
const cur = poly[i];
const next = poly[(i + 1) % poly.length];
const dPrev = dist(prev.x, prev.y, cur.x, cur.y) * radiusFrac;
const dNext = dist(cur.x, cur.y, next.x, next.y) * radiusFrac;
const a1 = atan2(prev.y - cur.y, prev.x - cur.x);
const a2 = atan2(next.y - cur.y, next.x - cur.x);
const p1 = { x: cur.x + cos(a1) * dPrev, y: cur.y + sin(a1) * dPrev };
const p2 = { x: cur.x + cos(a2) * dNext, y: cur.y + sin(a2) * dNext };
g.vertex(p1.x, p1.y);
g.quadraticVertex(cur.x, cur.y, p2.x, p2.y);
}
g.endShape(CLOSE);
}

function renderSegmentFill(seg) {
const bb = seg.bbox;
const w = max(4, floor(bb.w));
const h = max(4, floor(bb.h));
const { pixelSizePx, edgeFade } = noiseParamsForCanvas(CANVAS_SIZE);
const profile = seg.colourProfile;

shaderBuf.shader(noiseShader);
noiseShader.setUniform('uColorA', profile.a);
noiseShader.setUniform('uColorB', profile.b);
noiseShader.setUniform('uColorC', profile.c);
noiseShader.setUniform('uAngle', profile.angle);
noiseShader.setUniform('uNoiseIntensity', profile.noise);
noiseShader.setUniform('uPixelSize', pixelSizePx / CANVAS_SIZE);
noiseShader.setUniform('uEdgeFade', edgeFade);
noiseShader.setUniform('uSeedTex', seedTex);
shaderBuf.noStroke();
shaderBuf.rect(-CANVAS_SIZE / 2, -CANVAS_SIZE / 2, CANVAS_SIZE, CANVAS_SIZE);

const bx = constrain(floor(bb.x), 0, CANVAS_SIZE - 1);
const by = constrain(floor(bb.y), 0, CANVAS_SIZE - 1);
const cw = constrain(w, 1, CANVAS_SIZE - bx);
const ch = constrain(h, 1, CANVAS_SIZE - by);
const shaderCrop = shaderBuf.get(bx, by, cw, ch);
const cut = createGraphics(cw, ch);
cut.image(shaderCrop, 0, 0);
cut.drawingContext.globalCompositeOperation = 'destination-in';
cut.push();
cut.translate(-bx, -by);
cut.noStroke();
cut.fill(255);
drawPolyShape(cut, seg);
cut.pop();
cut.drawingContext.globalCompositeOperation = 'source-over';

if (PALETTE.blend) {
drawingContext.save();
drawingContext.globalCompositeOperation = PALETTE.blend;
image(cut, bx, by);
drawingContext.restore();
} else {
image(cut, bx, by);
}
}

function drawOutline(seg) {
if (seg.hasStroke) {
push();
noFill();
stroke(20, 20, 20, 200);
strokeWeight(1.6);
drawPolyShape(this, seg);
pop();
} else if (seg.sketchy) {
const bold = seg.sketchy === 'boldFew';
const occurrenceChance = bold ? 0.35 : 0.75;
if (random() < occurrenceChance) {
const lines = bold ? floor(random(7, 13)) : floor(random(2, 5));
const weight = bold ? random(2, 3.4) : random(0.5, 1.1);
push();
noFill();
stroke(15, 15, 15, bold ? 140 : 90);
strokeWeight(weight);
for (let i = 0; i < lines; i++) {
push();
translate(random(-2.5, 2.5), random(-2.5, 2.5));
rotate(random(-0.04, 0.04));
drawPolyShape(this, seg);
pop();
}
pop();
}
}
}

function drawBackground() {
const bg = PALETTE.background;
background(bg[0], bg[1], bg[2]);
if (PALETTE.bgGradient) {
push();
noStroke();
const steps = 60;
for (let i = 0; i < steps; i++) {
const t = i / steps;
const c1 = color(bg[0], bg[1], bg[2]);
const c2 = color(PALETTE.colorC[0] * 255, PALETTE.colorC[1] * 255, PALETTE.colorC[2] * 255);
fill(lerpColor(c1, c2, t * 0.35));
rect(0, t * CANVAS_SIZE, CANVAS_SIZE, CANVAS_SIZE / steps + 1);
}
pop();
}
}

function renderComposition() {
drawBackground();
const all = segments.concat(extraSegments, extraRotateSegments);
all.forEach(seg => {
renderSegmentFill(seg);
drawOutline(seg);
});
}