/** * Pure rendering logic — environment-agnostic. * * Uses only the standard CanvasRenderingContext2D API so it works * identically in Node (@napi-rs/canvas) and browsers. * * Generation pipeline: * 0. Archetype selection + shape palette + color hierarchy * 1. Background — style from archetype, gradient mesh for depth * 1b. Layered background — archetype-coherent shapes * 2. Composition mode + symmetry * 3. Focal points + void zones + hero avoidance field * 4. Flow field * 4b. Hero shape * 5. Shape layers — palette-driven selection, affinity-aware styles, * size echo, tangent placement, atmospheric depth * 5b. Recursive nesting * 6. Flow lines — variable color, branching, pressure simulation * 6b. Symmetry mirroring * 7. Noise texture * 8. Vignette * 9. Organic connecting curves * 10. Post-processing — color grading, chromatic aberration, bloom */ import { SacredColorScheme, hexWithAlpha, jitterColorHSL, desaturate, shiftTemperature, luminance, enforceContrast, buildColorHierarchy, pickHierarchyColor, pickColorGrade, evolveHierarchy, type ColorHierarchy } from "./canvas/colors"; import { enhanceShapeGeneration, drawMirroredShape, pickMirrorAxis, pickBlendMode, pickRenderStyle, type RenderStyle } from "./canvas/draw"; import { shapes } from "./canvas/shapes"; import { buildShapePalette, pickShapeFromPalette, pickStyleForShape, SHAPE_PROFILES } from "./canvas/shapes/affinity"; import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils"; import { DEFAULT_CONFIG, type GenerationConfig } from "../types"; import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes"; // ── Render style cost weights (normalized: fill-and-stroke = 1) ───── // Based on benchmark measurements. Used by the complexity budget to // cap total rendering work and downgrade expensive styles when needed. const RENDER_STYLE_COST: Record = { "fill-and-stroke": 1, "fill-only": 0.5, "stroke-only": 1, "double-stroke": 1.5, "dashed": 1, "watercolor": 7, "hatched": 3, "incomplete": 1, "stipple": 90, "stencil": 2, "noise-grain": 400, "wood-grain": 10, "marble-vein": 4, "fabric-weave": 6, "hand-drawn": 5, }; function downgradeRenderStyle(style: RenderStyle): RenderStyle { switch (style) { case "noise-grain": return "hatched"; case "stipple": return "dashed"; case "wood-grain": return "hatched"; case "watercolor": return "fill-and-stroke"; case "fabric-weave": return "hatched"; case "hand-drawn": return "fill-and-stroke"; case "marble-vein": return "stroke-only"; default: return style; } } // ── Shape categories for weighted selection (legacy fallback) ─────── const SACRED_SHAPES = [ "mandala", "flowerOfLife", "treeOfLife", "metatronsCube", "sriYantra", "seedOfLife", "vesicaPiscis", "torus", "eggOfLife", ]; // ── Composition modes ─────────────────────────────────────────────── const ALL_COMPOSITION_MODES: CompositionMode[] = [ "radial", "flow-field", "spiral", "grid-subdivision", "clustered", "golden-spiral", ]; // ── Helper: get position based on composition mode ────────────────── function getCompositionPosition( mode: CompositionMode, rng: () => number, width: number, height: number, shapeIndex: number, totalShapes: number, cx: number, cy: number, ): { x: number; y: number } { switch (mode) { case "radial": { const angle = rng() * Math.PI * 2; const maxR = Math.min(width, height) * 0.45; const r = Math.pow(rng(), 0.7) * maxR; return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }; } case "spiral": { const t = shapeIndex / totalShapes; const turns = 3 + rng() * 2; const angle = t * Math.PI * 2 * turns; const maxR = Math.min(width, height) * 0.42; const r = t * maxR + (rng() - 0.5) * maxR * 0.15; return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }; } case "grid-subdivision": { const cells = 3 + Math.floor(rng() * 3); const cellW = width / cells; const cellH = height / cells; const gx = Math.floor(rng() * cells); const gy = Math.floor(rng() * cells); return { x: gx * cellW + rng() * cellW, y: gy * cellH + rng() * cellH, }; } case "clustered": { const numClusters = 3 + Math.floor(rng() * 3); const ci = Math.floor(rng() * numClusters); const clusterRng = createRng(seedFromHash(String(ci), 999)); const clx = width * (0.15 + clusterRng() * 0.7); const cly = height * (0.15 + clusterRng() * 0.7); const spread = Math.min(width, height) * 0.18; return { x: clx + (rng() - 0.5) * spread * 2, y: cly + (rng() - 0.5) * spread * 2, }; } case "flow-field": default: { return { x: rng() * width, y: rng() * height }; } case "golden-spiral": { // Logarithmic spiral: r = a * e^(b*theta), with golden angle spacing const PHI = (1 + Math.sqrt(5)) / 2; const goldenAngle = 2 * Math.PI / (PHI * PHI); // ~137.5° in radians const t = shapeIndex / totalShapes; const angle = shapeIndex * goldenAngle + rng() * 0.3; const maxR = Math.min(width, height) * 0.44; // Shapes spiral outward with sqrt distribution for even area coverage const r = Math.sqrt(t) * maxR + (rng() - 0.5) * maxR * 0.08; return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }; } } } // ── Helper: positional color from hierarchy ───────────────────────── function getPositionalColor( x: number, y: number, width: number, height: number, hierarchy: ColorHierarchy, rng: () => number, ): string { // Blend position into color selection — shapes near center lean dominant const distFromCenter = Math.hypot(x - width / 2, y - height / 2) / Math.hypot(width / 2, height / 2); // Center = more dominant, edges = more accent if (distFromCenter < 0.35) { return jitterColorHSL(hierarchy.dominant, rng, 10, 0.08); } else if (distFromCenter < 0.7) { return jitterColorHSL(pickHierarchyColor(hierarchy, rng), rng, 8, 0.06); } else { // Edges: bias toward secondary/accent const roll = rng(); const color = roll < 0.4 ? hierarchy.secondary : roll < 0.75 ? hierarchy.accent : hierarchy.dominant; return jitterColorHSL(color, rng, 12, 0.08); } } // ── Helper: check if a position is inside a void zone ─────────────── function isInVoidZone( x: number, y: number, voidZones: Array<{ x: number; y: number; radius: number }>, ): boolean { for (const zone of voidZones) { if (Math.hypot(x - zone.x, y - zone.y) < zone.radius) return true; } return false; } // ── Spatial hash grid for O(1) density checks and nearest-neighbor ── class SpatialGrid { private cells: Map>; private cellSize: number; constructor(cellSize: number) { this.cells = new Map(); this.cellSize = cellSize; } private key(cx: number, cy: number): string { return `${cx},${cy}`; } insert(item: { x: number; y: number; size: number; shape: string }): void { const cx = Math.floor(item.x / this.cellSize); const cy = Math.floor(item.y / this.cellSize); const k = this.key(cx, cy); const cell = this.cells.get(k); if (cell) cell.push(item); else this.cells.set(k, [item]); } /** Count items within radius of (x, y) */ countNear(x: number, y: number, radius: number): number { const r2 = radius * radius; const minCx = Math.floor((x - radius) / this.cellSize); const maxCx = Math.floor((x + radius) / this.cellSize); const minCy = Math.floor((y - radius) / this.cellSize); const maxCy = Math.floor((y + radius) / this.cellSize); let count = 0; for (let cx = minCx; cx <= maxCx; cx++) { for (let cy = minCy; cy <= maxCy; cy++) { const cell = this.cells.get(this.key(cx, cy)); if (!cell) continue; for (const p of cell) { const dx = x - p.x; const dy = y - p.y; if (dx * dx + dy * dy < r2) count++; } } } return count; } /** Find nearest item to (x, y) */ findNearest(x: number, y: number, searchRadius: number): { x: number; y: number; size: number } | null { const minCx = Math.floor((x - searchRadius) / this.cellSize); const maxCx = Math.floor((x + searchRadius) / this.cellSize); const minCy = Math.floor((y - searchRadius) / this.cellSize); const maxCy = Math.floor((y + searchRadius) / this.cellSize); let nearest: { x: number; y: number; size: number } | null = null; let bestDist2 = Infinity; for (let cx = minCx; cx <= maxCx; cx++) { for (let cy = minCy; cy <= maxCy; cy++) { const cell = this.cells.get(this.key(cx, cy)); if (!cell) continue; for (const p of cell) { const dx = x - p.x; const dy = y - p.y; const d2 = dx * dx + dy * dy; if (d2 > 0 && d2 < bestDist2) { bestDist2 = d2; nearest = p; } } } } return nearest; } } // ── Helper: density check (legacy wrapper) ────────────────────────── function localDensity( x: number, y: number, positions: Array<{ x: number; y: number; size: number }>, radius: number, ): number { let count = 0; for (const p of positions) { if (Math.hypot(x - p.x, y - p.y) < radius) count++; } return count; } // ── Helper: draw background based on archetype style ──────────────── function drawBackground( ctx: CanvasRenderingContext2D, style: BackgroundStyle, bgStart: string, bgEnd: string, width: number, height: number, cx: number, cy: number, bgRadius: number, rng: () => number, colors: string[], ): void { switch (style) { case "radial-light": { const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, bgRadius); grad.addColorStop(0, "#f0ece4"); grad.addColorStop(1, bgStart); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); break; } case "linear-horizontal": { const grad = ctx.createLinearGradient(0, 0, width, 0); grad.addColorStop(0, bgStart); grad.addColorStop(1, bgEnd); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); break; } case "linear-diagonal": { const grad = ctx.createLinearGradient(0, 0, width, height); grad.addColorStop(0, bgStart); grad.addColorStop(0.5, bgEnd); grad.addColorStop(1, bgStart); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); break; } case "solid-dark": { ctx.fillStyle = bgStart; ctx.fillRect(0, 0, width, height); break; } case "solid-light": { ctx.fillStyle = "#f5f2eb"; ctx.fillRect(0, 0, width, height); break; } case "multi-stop": { const grad = ctx.createLinearGradient(0, 0, width * 0.7, height); grad.addColorStop(0, bgStart); grad.addColorStop(0.33, bgEnd); if (colors.length > 0) { const midColor = hexWithAlpha(colors[0], 1).replace(/rgba\((\d+),(\d+),(\d+),[^)]+\)/, (_, r, g, b) => { const darken = (v: string) => Math.round(parseInt(v) * 0.4).toString(16).padStart(2, "0"); return `#${darken(r)}${darken(g)}${darken(b)}`; }); grad.addColorStop(0.66, midColor); } grad.addColorStop(1, bgStart); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); break; } case "radial-dark": default: { const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, bgRadius); grad.addColorStop(0, bgStart); grad.addColorStop(1, bgEnd); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); break; } } } // ── Shape constellations — pre-composed groups of shapes ──────── interface ConstellationDef { name: string; /** Generate member positions/shapes relative to center */ build: (rng: () => number, baseSize: number) => Array<{ dx: number; dy: number; shape: string; size: number; rotation: number; }>; } const CONSTELLATIONS: ConstellationDef[] = [ { name: "flanked-triangle", build: (rng, baseSize) => { const gap = baseSize * (0.6 + rng() * 0.3); return [ { dx: 0, dy: 0, shape: "triangle", size: baseSize, rotation: rng() * 360 }, { dx: -gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 }, { dx: gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 }, ]; }, }, { name: "hexagon-ring", build: (rng, baseSize) => { const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = []; const count = 5 + Math.floor(rng() * 2); const ringR = baseSize * 0.6; for (let i = 0; i < count; i++) { const angle = (i / count) * Math.PI * 2; members.push({ dx: Math.cos(angle) * ringR, dy: Math.sin(angle) * ringR, shape: "hexagon", size: baseSize * (0.25 + rng() * 0.1), rotation: (angle * 180) / Math.PI, }); } return members; }, }, { name: "spiral-dots", build: (rng, baseSize) => { const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = []; const count = 7 + Math.floor(rng() * 5); const turns = 1.5 + rng(); for (let i = 0; i < count; i++) { const t = i / count; const angle = t * Math.PI * 2 * turns; const r = t * baseSize * 0.7; members.push({ dx: Math.cos(angle) * r, dy: Math.sin(angle) * r, shape: "circle", size: baseSize * (0.08 + (1 - t) * 0.12), rotation: 0, }); } return members; }, }, { name: "diamond-cluster", build: (rng, baseSize) => { const gap = baseSize * 0.45; return [ { dx: 0, dy: -gap, shape: "diamond", size: baseSize * 0.4, rotation: 0 }, { dx: gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: 15 }, { dx: 0, dy: gap, shape: "diamond", size: baseSize * 0.3, rotation: 30 }, { dx: -gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: -15 }, ]; }, }, { name: "crescent-pair", build: (rng, baseSize) => { const gap = baseSize * 0.5; return [ { dx: -gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.5, rotation: rng() * 30 }, { dx: gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.45, rotation: 180 + rng() * 30 }, ]; }, }, ]; // ── Main render function ──────────────────────────────────────────── export function renderHashArt( ctx: CanvasRenderingContext2D, gitHash: string, config: Partial = {}, ): void { const finalConfig: GenerationConfig = { ...DEFAULT_CONFIG, ...config }; const _dt = finalConfig._debugTiming; const _t = _dt ? () => performance.now() : undefined; let _p = _t ? _t() : 0; function _mark(name: string) { if (!_dt || !_t) return; const now = _t(); _dt.phases[name] = (now - _p); _p = now; } const rng = createRng(seedFromHash(gitHash)); // ── 0. Select archetype — fundamentally different visual personality ── const archetype = selectArchetype(rng); // Archetype overrides defaults, but explicit user config wins const { width, height, } = finalConfig; const gridSize = config.gridSize ?? archetype.gridSize; const layers = config.layers ?? archetype.layers; const minShapeSize = config.minShapeSize ?? archetype.minShapeSize; const maxShapeSize = config.maxShapeSize ?? archetype.maxShapeSize; const baseOpacity = config.baseOpacity ?? archetype.baseOpacity; const opacityReduction = config.opacityReduction ?? archetype.opacityReduction; const shapesPerLayer = finalConfig.shapesPerLayer || Math.floor(gridSize * gridSize * 1.5); const colorScheme = new SacredColorScheme(gitHash); const colors = colorScheme.getColorsByMode(archetype.paletteMode); const [bgStart, bgEnd] = colorScheme.getBackgroundColorsByMode(archetype.paletteMode); const tempMode = colorScheme.getTemperatureMode(); const fgTempTarget: "warm" | "cool" | null = tempMode === "warm-bg" ? "cool" : tempMode === "cool-bg" ? "warm" : null; // ── 0b. Color hierarchy — dominant/secondary/accent weighting ── const colorHierarchy = buildColorHierarchy(colors, rng); // ── 0c. Shape palette — curated shapes that work well together ── // Merge custom shapes into a combined registry const customShapeNames: string[] = []; type DrawFunction = (ctx: CanvasRenderingContext2D, size: number, config?: any) => void; let activeShapes: Record | undefined; if (finalConfig.customShapes && Object.keys(finalConfig.customShapes).length > 0) { activeShapes = { ...shapes }; for (const [name, def] of Object.entries(finalConfig.customShapes)) { // Wrap CustomDrawFunction (ctx, size, rng) into DrawFunction (ctx, size, config?) const customDraw = def.draw; activeShapes[name] = (ctx, size, config?) => { customDraw(ctx, size, config?.rng ?? Math.random); }; // Register profile for affinity system (inlined to avoid ESM interop issues) SHAPE_PROFILES[name] = { tier: def.profile?.tier ?? 2, minSizeFraction: def.profile?.minSizeFraction ?? 0.05, maxSizeFraction: def.profile?.maxSizeFraction ?? 1.0, affinities: def.profile?.affinities ?? ["circle", "square"], category: "procedural", heroCandidate: def.profile?.heroCandidate ?? false, bestStyles: def.profile?.bestStyles ?? ["fill-and-stroke", "watercolor"], }; customShapeNames.push(name); } } const shapeNames = Object.keys(activeShapes ?? shapes); const shapePalette = buildShapePalette(rng, shapeNames, archetype.name); // ── 0d. Color grading — unified tone for the whole image ─────── const colorGrade = pickColorGrade(rng); // ── 0e. Light direction — consistent shadow angle ────────────── const lightAngle = rng() * Math.PI * 2; // ── 0f. Palette evolution — hue drift direction across layers ── const paletteHueShift = (rng() - 0.5) * 40; // -20° to +20° total drift const scaleFactor = Math.min(width, height) / 1024; const adjustedMinSize = minShapeSize * scaleFactor; const adjustedMaxSize = maxShapeSize * scaleFactor; const cx = width / 2; const cy = height / 2; _mark("0_setup"); // ── 1. Background ────────────────────────────────────────────── const bgRadius = Math.hypot(cx, cy); drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors); // Gradient mesh overlay — 3-4 color control points for richer backgrounds // Use source-over instead of soft-light for cheaper compositing const meshPoints = 3 + Math.floor(rng() * 2); ctx.globalAlpha = 1; for (let i = 0; i < meshPoints; i++) { const mx = rng() * width; const my = rng() * height; const mRadius = Math.min(width, height) * (0.3 + rng() * 0.4); const mColor = pickHierarchyColor(colorHierarchy, rng); const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius); grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06)); grad.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = grad; // Clip to gradient bounding box — avoids blending transparent pixels const gx = Math.max(0, mx - mRadius); const gy = Math.max(0, my - mRadius); const gw = Math.min(width, mx + mRadius) - gx; const gh = Math.min(height, my + mRadius) - gy; ctx.fillRect(gx, gy, gw, gh); } // Compute average background luminance for contrast enforcement const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2; // ── 1b. Layered background — archetype-coherent shapes ───────── // Use source-over with pre-multiplied alpha instead of soft-light // for much cheaper compositing (soft-light requires per-pixel blend) const bgShapeCount = 3 + Math.floor(rng() * 4); for (let i = 0; i < bgShapeCount; i++) { const bx = rng() * width; const by = rng() * height; const bSize = (width * 0.3 + rng() * width * 0.5); const bColor = pickHierarchyColor(colorHierarchy, rng); ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light ctx.fillStyle = hexWithAlpha(bColor, 0.15); ctx.beginPath(); // Use archetype-appropriate background shapes if (archetype.name === "geometric-precision" || archetype.name === "op-art") { ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5)); } else { ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2); } ctx.fill(); } // Subtle concentric rings from center — batched into single stroke const ringCount = 2 + Math.floor(rng() * 3); ctx.globalAlpha = 0.02 + rng() * 0.03; ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1); ctx.lineWidth = 1 * scaleFactor; ctx.beginPath(); for (let i = 1; i <= ringCount; i++) { const r = (Math.min(width, height) * 0.15) * i; ctx.moveTo(cx + r, cy); ctx.arc(cx, cy, r, 0, Math.PI * 2); } ctx.stroke(); // ── 1c. Background pattern layer — subtle textured paper ─────── const bgPatternRoll = rng(); if (bgPatternRoll < 0.6) { ctx.save(); const patternOpacity = 0.02 + rng() * 0.04; const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15); if (bgPatternRoll < 0.2) { // Dot grid — use fillRect instead of arcs (much cheaper, no path building) const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015)); const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16)); ctx.globalAlpha = patternOpacity; ctx.fillStyle = patternColor; let dotCount = 0; for (let px = 0; px < width && dotCount < 2000; px += dotSpacing) { for (let py = 0; py < height && dotCount < 2000; py += dotSpacing) { ctx.fillRect(px, py, dotDiam, dotDiam); dotCount++; } } } else if (bgPatternRoll < 0.4) { // Diagonal lines — batched into a single path, capped at 300 lines const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02)); ctx.globalAlpha = patternOpacity; ctx.strokeStyle = patternColor; ctx.lineWidth = 0.5 * scaleFactor; const diag = Math.hypot(width, height); ctx.beginPath(); let lineCount = 0; for (let d = -diag; d < diag && lineCount < 300; d += lineSpacing) { ctx.moveTo(d, 0); ctx.lineTo(d + height, height); lineCount++; } ctx.stroke(); } else { // Tessellation — hexagonal grid, capped at 500 hexagons const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02)); const tessH = tessSize * Math.sqrt(3); ctx.globalAlpha = patternOpacity * 0.7; ctx.strokeStyle = patternColor; ctx.lineWidth = 0.4 * scaleFactor; // Pre-compute hex vertex offsets (avoid trig per vertex) const hexVx: number[] = []; const hexVy: number[] = []; for (let s = 0; s < 6; s++) { const angle = (Math.PI / 3) * s - Math.PI / 6; hexVx.push(Math.cos(angle) * tessSize * 0.5); hexVy.push(Math.sin(angle) * tessSize * 0.5); } ctx.beginPath(); let hexCount = 0; for (let row = 0; row * tessH < height + tessH && hexCount < 500; row++) { const offsetX = (row % 2) * tessSize * 0.75; for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++) { const hx = col * tessSize * 1.5 + offsetX; const hy = row * tessH; ctx.moveTo(hx + hexVx[0], hy + hexVy[0]); for (let s = 1; s < 6; s++) { ctx.lineTo(hx + hexVx[s], hy + hexVy[s]); } ctx.closePath(); hexCount++; } } ctx.stroke(); } ctx.restore(); } ctx.globalCompositeOperation = "source-over"; _mark("1_background"); // ── 2. Composition mode — archetype-aware selection ────────────── const compositionMode: CompositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : ALL_COMPOSITION_MODES[Math.floor(rng() * ALL_COMPOSITION_MODES.length)]; // ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ────── type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad"; const symRoll = rng(); const symmetryMode: SymmetryMode = symRoll < 0.10 ? "bilateral-x" : symRoll < 0.20 ? "bilateral-y" : symRoll < 0.25 ? "quad" : "none"; // ── 3. Focal points + void zones (archetype-aware) ─────────────── const THIRDS_POINTS = [ { x: 1 / 3, y: 1 / 3 }, { x: 2 / 3, y: 1 / 3 }, { x: 1 / 3, y: 2 / 3 }, { x: 2 / 3, y: 2 / 3 }, ]; const numFocal = 1 + Math.floor(rng() * 2); const focalPoints: Array<{ x: number; y: number; strength: number }> = []; for (let f = 0; f < numFocal; f++) { if (rng() < 0.7) { const tp = THIRDS_POINTS[Math.floor(rng() * THIRDS_POINTS.length)]; focalPoints.push({ x: width * (tp.x + (rng() - 0.5) * 0.08), y: height * (tp.y + (rng() - 0.5) * 0.08), strength: 0.3 + rng() * 0.4, }); } else { focalPoints.push({ x: width * (0.2 + rng() * 0.6), y: height * (0.2 + rng() * 0.6), strength: 0.3 + rng() * 0.4, }); } } // Archetype-aware void zones: dense archetypes get fewer/no voids, // minimal archetypes get golden-ratio positioned voids const PHI = (1 + Math.sqrt(5)) / 2; const isMinimalArchetype = archetype.gridSize <= 3; const isDenseArchetype = archetype.gridSize >= 8; const numVoids = isDenseArchetype ? 0 : (Math.floor(rng() * 2) + 1); const voidZones: Array<{ x: number; y: number; radius: number }> = []; for (let v = 0; v < numVoids; v++) { if (isMinimalArchetype) { // Place voids at golden-ratio positions for intentional negative space const gx = (v === 0) ? 1 / PHI : 1 - 1 / PHI; const gy = (v === 0) ? 1 - 1 / PHI : 1 / PHI; voidZones.push({ x: width * (gx + (rng() - 0.5) * 0.05), y: height * (gy + (rng() - 0.5) * 0.05), radius: Math.min(width, height) * (0.08 + rng() * 0.08), }); } else { voidZones.push({ x: width * (0.15 + rng() * 0.7), y: height * (0.15 + rng() * 0.7), radius: Math.min(width, height) * (0.06 + rng() * 0.1), }); } } function applyFocalBias(rx: number, ry: number): [number, number] { let nearest = focalPoints[0]; let minDist = Infinity; for (const fp of focalPoints) { const d = Math.hypot(rx - fp.x, ry - fp.y); if (d < minDist) { minDist = d; nearest = fp; } } const pull = nearest.strength * rng() * 0.5; return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull]; } // ── 3b. Void zone decoration — intentional negative space ──── for (const zone of voidZones) { // Subtle halo ring around void zones ctx.globalAlpha = 0.04 + rng() * 0.04; ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.2); ctx.lineWidth = 1.5 * scaleFactor; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2); ctx.stroke(); // ~50% chance: scatter tiny dots inside the void — batched into single path if (rng() < 0.5) { const dotCount = 3 + Math.floor(rng() * 6); ctx.globalAlpha = 0.06 + rng() * 0.04; ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15); ctx.beginPath(); for (let d = 0; d < dotCount; d++) { const angle = rng() * Math.PI * 2; const dist = rng() * zone.radius * 0.7; const dotR = (1 + rng() * 3) * scaleFactor; ctx.moveTo( zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist, ); ctx.arc( zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2, ); } ctx.fill(); } // ~30% chance: thin concentric ring inside if (rng() < 0.3) { ctx.globalAlpha = 0.03 + rng() * 0.03; ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1); ctx.lineWidth = 0.5 * scaleFactor; const innerR = zone.radius * (0.4 + rng() * 0.3); ctx.beginPath(); ctx.arc(zone.x, zone.y, innerR, 0, Math.PI * 2); ctx.stroke(); } } ctx.globalAlpha = 1; _mark("2_3_composition_focal"); // ── 4. Flow field — simplex noise for organic variation ───────── // Create a seeded simplex noise field (unique per hash) const noiseFieldRng = createRng(seedFromHash(gitHash, 333)); const simplexNoise = createSimplexNoise(noiseFieldRng); const fbmNoise = createFBM(simplexNoise, 3, 2.0, 0.5); const fieldAngleBase = rng() * Math.PI * 2; const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency function flowAngle(x: number, y: number): number { // Sample FBM noise at the position, scaled by frequency const nx = (x / width) * fieldFreq; const ny = (y / height) * fieldFreq; return fieldAngleBase + fbmNoise(nx, ny) * Math.PI; } // Noise-based size modulation — shapes in "high noise" areas get scaled function noiseSizeModulation(x: number, y: number): number { const n = simplexNoise((x / width) * 3, (y / height) * 3); // Map [-1,1] to [0.7, 1.3] — subtle terrain-like size variation return 0.7 + (n + 1) * 0.3; } // Track all placed shapes for density checks and connecting curves const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = []; // Spatial grid for O(1) density and nearest-neighbor lookups const densityCheckRadius = Math.min(width, height) * 0.08; const spatialGrid = new SpatialGrid(densityCheckRadius); // Hero avoidance radius — shapes near the hero orient toward it let heroCenter: { x: number; y: number; size: number } | null = null; // ── 4b. Hero shape — a dominant focal element ─────────────────── if (archetype.heroShape && rng() < 0.6) { const heroFocal = focalPoints[0]; // Use shape palette hero candidates const heroPool = [...shapePalette.primary, ...shapePalette.supporting] .filter((s) => SHAPE_PROFILES[s]?.heroCandidate && shapeNames.includes(s)); const heroShape = heroPool.length > 0 ? heroPool[Math.floor(rng() * heroPool.length)] : shapeNames[Math.floor(rng() * shapeNames.length)]; const heroSize = adjustedMaxSize * (0.8 + rng() * 0.5); const heroRotation = rng() * 360; const heroFill = hexWithAlpha( enforceContrast(jitterColorHSL(colorHierarchy.dominant, rng, 6, 0.05), bgLum), 0.15 + rng() * 0.2, ); const heroStroke = enforceContrast(jitterColorHSL(colorHierarchy.accent, rng, 6, 0.05), bgLum); // Get best style for this hero shape const heroProfile = SHAPE_PROFILES[heroShape]; const heroStyle: RenderStyle = heroProfile ? (heroProfile.bestStyles[Math.floor(rng() * heroProfile.bestStyles.length)] as RenderStyle) : (rng() < 0.4 ? "watercolor" : "fill-and-stroke"); ctx.globalAlpha = 0.5 + rng() * 0.2; enhanceShapeGeneration(ctx, heroShape, heroFocal.x, heroFocal.y, { fillColor: heroFill, strokeColor: heroStroke, strokeWidth: (1.5 + rng() * 2) * scaleFactor, size: heroSize, rotation: heroRotation, proportionType: "GOLDEN_RATIO", glowRadius: (12 + rng() * 20) * scaleFactor, glowColor: hexWithAlpha(heroStroke, 0.4), gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1), renderStyle: heroStyle, rng, lightAngle, scaleFactor, activeShapes, }); heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize }; shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape }); spatialGrid.insert({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape }); } _mark("4_flowfield_hero"); // ── 5. Shape layers ──────────────────────────────────────────── const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15); // ── Complexity budget — caps total rendering work ────────────── // Budget scales with pixel area so larger canvases get proportionally // more headroom. The multiplier extras (glazing, echoes, nesting, // constellations, rhythm) are gated behind the budget; when it runs // low they are skipped. When it's exhausted, expensive render styles // are downgraded to cheaper alternatives. // // RNG values are always consumed even when skipping, so the // deterministic sequence for shapes that *do* render is preserved. const pixelArea = width * height; const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels let complexityBudget = (pixelArea / 1_000_000) * BUDGET_PER_MEGAPIXEL; const totalBudget = complexityBudget; const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras let extrasSpent = 0; // Hard cap on clip-heavy render styles (stipple, noise-grain). // These generate O(size²) fillRect calls per shape and dominate // worst-case render time. Cap scales with pixel area. const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1_000_000))); let clipHeavyCount = 0; for (let layer = 0; layer < layers; layer++) { const layerRatio = layers > 1 ? layer / (layers - 1) : 0; const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3); const layerOpacity = Math.max(0.15, baseOpacity - layer * opacityReduction); const layerSizeScale = 1 - layer * 0.15; // Per-layer blend mode const layerBlend = pickBlendMode(rng); ctx.globalCompositeOperation = layerBlend; // Per-layer render style bias — prefer archetype styles const layerRenderStyle: RenderStyle = rng() < 0.6 ? archetype.preferredStyles[Math.floor(rng() * archetype.preferredStyles.length)] : pickRenderStyle(rng); // Atmospheric desaturation for later layers const atmosphericDesat = layerRatio * 0.3; // Depth-of-field simulation — later layers are "further away" // Reduce stroke widths and shift colors toward the background const dofFactor = 1 - layerRatio * 0.5; // 1.0 for front layer, 0.5 for back const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg // Color palette evolution — hue-rotate the hierarchy per layer const layerHierarchy = evolveHierarchy(colorHierarchy, layerRatio, paletteHueShift); // Focal depth: shapes near focal points get more detail const focalDetailBoost = (px: number, py: number): number => { let minFocalDist = Infinity; for (const fp of focalPoints) { const d = Math.hypot(px - fp.x, py - fp.y); if (d < minFocalDist) minFocalDist = d; } const maxDist = Math.hypot(width, height) * 0.5; return Math.max(0, 1 - minFocalDist / maxDist); // 1.0 at focal, 0.0 at edges }; for (let i = 0; i < numShapes; i++) { // Position from composition mode, then focal bias const rawPos = getCompositionPosition( compositionMode, rng, width, height, i, numShapes, cx, cy, ); const [x, y] = applyFocalBias(rawPos.x, rawPos.y); // Skip shapes in void zones, reduce in dense areas if (isInVoidZone(x, y, voidZones)) { if (rng() < 0.85) continue; } if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) { if (rng() < 0.6) continue; } // Power distribution for size — archetype controls the curve const sizeT = Math.pow(rng(), archetype.sizePower); const size = (adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) * layerSizeScale * noiseSizeModulation(x, y); // Size fraction for affinity-aware shape selection const sizeFraction = size / adjustedMaxSize; // Palette-driven shape selection (replaces naive pickShape) const shape = pickShapeFromPalette(shapePalette, rng, sizeFraction); // Flow-field rotation in flow-field mode, random otherwise let rotation = compositionMode === "flow-field" ? (flowAngle(x, y) * 180) / Math.PI + (rng() - 0.5) * 30 : rng() * 360; // Hero avoidance: shapes near the hero orient toward it if (heroCenter) { const distToHero = Math.hypot(x - heroCenter.x, y - heroCenter.y); const heroInfluence = heroCenter.size * 1.5; if (distToHero < heroInfluence && distToHero > 0) { const angleToHero = Math.atan2(heroCenter.y - y, heroCenter.x - x) * 180 / Math.PI; const blendFactor = 1 - (distToHero / heroInfluence); rotation = rotation + (angleToHero - rotation) * blendFactor * 0.4; } } // Positional color from hierarchy + jitter (using evolved layer palette) let fillBase = getPositionalColor(x, y, width, height, layerHierarchy, rng); const strokeBase = pickHierarchyColor(layerHierarchy, rng); // Desaturate colors on later layers for depth if (atmosphericDesat > 0) { fillBase = desaturate(fillBase, atmosphericDesat); } // Temperature contrast: shift foreground shapes opposite to background if (fgTempTarget) { fillBase = shiftTemperature(fillBase, fgTempTarget, 0.15 + layerRatio * 0.1); } const fillColor = enforceContrast(jitterColorHSL(fillBase, rng, 6, 0.05), bgLum); const strokeColor = enforceContrast(jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum); // Semi-transparent fill const fillAlpha = 0.2 + rng() * 0.5; const transparentFill = hexWithAlpha(fillColor, fillAlpha); const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor * dofStrokeScale; // Depth-of-field: reduce opacity slightly for distant layers const dofOpacityScale = 1 - dofContrastReduction; ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5) * dofOpacityScale; // Glow on sacred shapes more often — scaled by archetype const isSacred = SACRED_SHAPES.includes(shape); const baseGlowChance = isSacred ? 0.45 : 0.2; const glowChance = baseGlowChance * archetype.glowMultiplier; const hasGlow = rng() < glowChance; const glowRadius = hasGlow ? (8 + rng() * 20) * scaleFactor : 0; // Gradient fill on ~30% const hasGradient = rng() < 0.3; const gradientEnd = hasGradient ? jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1) : undefined; // Affinity-aware render style selection const shapeRenderStyle = pickStyleForShape(shape, layerRenderStyle, rng) as RenderStyle; // Organic edge jitter — applied via watercolor style on ~15% of shapes const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke"; let finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle; // Budget check: downgrade expensive styles proportionally — // the more expensive the style, the earlier it gets downgraded. // noise-grain (400) downgrades when budget < 20% remaining, // stipple (90) when < 82%, wood-grain (10) when < 98%. let styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1; if (styleCost > 3) { const downgradeThreshold = Math.min(0.85, styleCost / 500); if (complexityBudget < totalBudget * (1 - downgradeThreshold)) { finalRenderStyle = downgradeRenderStyle(finalRenderStyle); styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1; } } // Hard cap: clip-heavy styles (stipple, noise-grain) are limited // to MAX_CLIP_HEAVY_SHAPES total across the entire render. if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) { finalRenderStyle = downgradeRenderStyle(finalRenderStyle); styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1; } if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") { clipHeavyCount++; } // Consistent light direction — subtle shadow offset const shadowDist = hasGlow ? 0 : (size * 0.02); const shadowOffX = shadowDist * Math.cos(lightAngle); const shadowOffY = shadowDist * Math.sin(lightAngle); // ── 5a. Tangent placement — nudge toward nearest shape edge ── let finalX = x; let finalY = y; if (shapePositions.length > 0 && rng() < 0.25) { // Use spatial grid for O(1) nearest-neighbor lookup const searchRadius = adjustedMaxSize * 3; const nearestPos = spatialGrid.findNearest(x, y, searchRadius); if (nearestPos) { const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y); // Target distance: edges kissing (sum of half-sizes) const targetDist = (size + nearestPos.size) * 0.5; if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) { const angle = Math.atan2(y - nearestPos.y, x - nearestPos.x); finalX = nearestPos.x + Math.cos(angle) * targetDist; finalY = nearestPos.y + Math.sin(angle) * targetDist; // Keep in bounds finalX = Math.max(0, Math.min(width, finalX)); finalY = Math.max(0, Math.min(height, finalY)); } } } // ── 5b. Shape mirroring — basic shapes get reflected copies ── const mirrorAxis = pickMirrorAxis(rng); const isBasicShape = ["circle", "triangle", "square", "hexagon", "star", "diamond", "crescent", "penroseTile", "reuleauxTriangle"].includes(shape); const shouldMirror = mirrorAxis !== null && isBasicShape && size > adjustedMaxSize * 0.2; const shapeConfig = { fillColor: transparentFill, strokeColor, strokeWidth, size, rotation, proportionType: "GOLDEN_RATIO" as const, glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0), glowColor: hasGlow ? hexWithAlpha(fillColor, 0.6) : (shadowDist > 0 ? "rgba(0,0,0,0.08)" : undefined), gradientFillEnd: gradientEnd, renderStyle: finalRenderStyle, rng, lightAngle, scaleFactor, activeShapes, }; if (shouldMirror) { drawMirroredShape(ctx, shape, finalX, finalY, { ...shapeConfig, mirrorAxis: mirrorAxis!, mirrorGap: size * (0.1 + rng() * 0.3), }); complexityBudget -= styleCost * 2; // mirrored = 2 shapes } else { enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig); complexityBudget -= styleCost; } // ── Extras budget gate — skip multiplier sections when over budget ── const extrasAllowed = extrasSpent < budgetForExtras; // ── Glazing — luminous multi-pass transparency on ~20% of shapes ── if (rng() < 0.2 && size > adjustedMinSize * 2) { const glazePasses = 2 + Math.floor(rng() * 2); if (extrasAllowed) { for (let g = 0; g < glazePasses; g++) { const glazeScale = 1 - (g + 1) * 0.12; const glazeAlpha = 0.08 + g * 0.04; ctx.globalAlpha = glazeAlpha; enhanceShapeGeneration(ctx, shape, finalX, finalY, { fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1), strokeColor: "rgba(0,0,0,0)", strokeWidth: 0, size: size * glazeScale, rotation, proportionType: "GOLDEN_RATIO", renderStyle: "fill-only", rng, activeShapes, }); } extrasSpent += glazePasses; } // RNG consumed by glazePasses calculation above regardless } shapePositions.push({ x: finalX, y: finalY, size, shape }); spatialGrid.insert({ x: finalX, y: finalY, size, shape }); // ── 5c. Size echo — large shapes spawn trailing smaller copies ── if (size > adjustedMaxSize * 0.5 && rng() < 0.2) { const echoCount = 2 + Math.floor(rng() * 2); const echoAngle = rng() * Math.PI * 2; if (extrasAllowed) { for (let e = 0; e < echoCount; e++) { const echoScale = 0.3 - e * 0.08; const echoDist = size * (0.6 + e * 0.4); const echoX = finalX + Math.cos(echoAngle) * echoDist; const echoY = finalY + Math.sin(echoAngle) * echoDist; const echoSize = size * Math.max(0.1, echoScale); if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue; ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1); enhanceShapeGeneration(ctx, shape, echoX, echoY, { fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6), strokeColor: hexWithAlpha(strokeColor, 0.4), strokeWidth: strokeWidth * 0.6, size: echoSize, rotation: rotation + (e + 1) * 15, proportionType: "GOLDEN_RATIO", renderStyle: finalRenderStyle, rng, activeShapes, }); shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape }); spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape }); } extrasSpent += echoCount * styleCost; } // RNG for echoCount + echoAngle consumed above regardless } // ── 5d. Recursive nesting ────────────────────────────────── // Focal depth: shapes near focal points get more detail const focalProximity = focalDetailBoost(finalX, finalY); const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) { const innerCount = 1 + Math.floor(rng() * 3); if (extrasAllowed) { for (let n = 0; n < innerCount; n++) { // Pick inner shape from palette affinities const innerSizeFraction = (size * 0.25) / adjustedMaxSize; const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction); const innerSize = size * (0.15 + rng() * 0.25); const innerOffX = (rng() - 0.5) * size * 0.4; const innerOffY = (rng() - 0.5) * size * 0.4; const innerRot = rng() * 360; const innerFill = hexWithAlpha( jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4, ); let innerStyle = pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle; // Apply clip-heavy cap to nested shapes too if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) { innerStyle = downgradeRenderStyle(innerStyle); } if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++; ctx.globalAlpha = layerOpacity * 0.7; enhanceShapeGeneration( ctx, innerShape, finalX + innerOffX, finalY + innerOffY, { fillColor: innerFill, strokeColor: hexWithAlpha(strokeColor, 0.5), strokeWidth: strokeWidth * 0.6, size: innerSize, rotation: innerRot, proportionType: "GOLDEN_RATIO", renderStyle: innerStyle, rng, activeShapes, }, ); extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1; } } else { // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls for (let n = 0; n < innerCount; n++) { rng(); rng(); rng(); rng(); rng(); rng(); rng(); rng(); } } } // ── 5e. Shape constellations — pre-composed groups ───────── const constellationChance = 0.12 + focalProximity * 0.1; // 12-22% near focal if (size > adjustedMaxSize * 0.35 && rng() < constellationChance) { const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)]; const members = constellation.build(rng, size); const groupRotation = rng() * Math.PI * 2; if (extrasAllowed) { const cosR = Math.cos(groupRotation); const sinR = Math.sin(groupRotation); for (const member of members) { // Rotate the group offset by the group rotation const mx = finalX + member.dx * cosR - member.dy * sinR; const my = finalY + member.dx * sinR + member.dy * cosR; if (mx < 0 || mx > width || my < 0 || my > height) continue; const memberFill = hexWithAlpha( jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8, ); const memberStroke = enforceContrast( jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum, ); ctx.globalAlpha = layerOpacity * 0.6; // Use the member's shape if available, otherwise fall back to palette const memberShape = shapeNames.includes(member.shape) ? member.shape : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize); let memberStyle = pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle; // Apply clip-heavy cap to constellation members too if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) { memberStyle = downgradeRenderStyle(memberStyle); } if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++; enhanceShapeGeneration(ctx, memberShape, mx, my, { fillColor: memberFill, strokeColor: memberStroke, strokeWidth: strokeWidth * 0.7, size: member.size, rotation: member.rotation + (groupRotation * 180) / Math.PI, proportionType: "GOLDEN_RATIO", renderStyle: memberStyle, rng, activeShapes, }); shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape }); spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape }); extrasSpent += RENDER_STYLE_COST[memberStyle] ?? 1; } } else { // Drain RNG — each member consumes ~6 rng calls for colors/style for (let m = 0; m < members.length; m++) { rng(); rng(); rng(); rng(); rng(); rng(); } } } // ── 5f. Rhythm placement — deliberate geometric progressions ── // ~12% of medium-large shapes spawn a rhythmic sequence if (size > adjustedMaxSize * 0.25 && rng() < 0.12) { const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes const rhythmAngle = rng() * Math.PI * 2; const rhythmSpacing = size * (0.8 + rng() * 0.6); const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step const rhythmShape = shape; // same shape for visual rhythm if (extrasAllowed) { let rhythmSize = size * 0.6; for (let r = 0; r < rhythmCount; r++) { const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1); const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1); if (rx < 0 || rx > width || ry < 0 || ry > height) break; if (isInVoidZone(rx, ry, voidZones)) break; rhythmSize *= rhythmDecay; if (rhythmSize < adjustedMinSize) break; const rhythmAlpha = layerOpacity * (0.6 - r * 0.08); ctx.globalAlpha = Math.max(0.1, rhythmAlpha); const rhythmFill = hexWithAlpha( jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7, ); enhanceShapeGeneration(ctx, rhythmShape, rx, ry, { fillColor: rhythmFill, strokeColor: hexWithAlpha(strokeColor, 0.5), strokeWidth: strokeWidth * 0.7, size: rhythmSize, rotation: rotation + (r + 1) * 12, proportionType: "GOLDEN_RATIO", renderStyle: finalRenderStyle, rng, activeShapes, }); shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape }); spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape }); } extrasSpent += rhythmCount * styleCost; } else { // Drain RNG — each rhythm step consumes ~3 rng calls for colors for (let r = 0; r < rhythmCount; r++) { rng(); rng(); rng(); } } } } } // Reset blend mode for post-processing passes ctx.globalCompositeOperation = "source-over"; if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; } _mark("5_shape_layers"); // ── 5g. (Portal/cutout feature removed — replaced by custom shapes API) ── _mark("5g_portals"); // ── 6. Flow-line pass — variable color, branching, pressure ──── // Optimized: collect all segments into width-quantized buckets, then // render each bucket as a single batched path. This reduces // beginPath/stroke calls from O(segments) to O(buckets). const baseFlowLines = 6 + Math.floor(rng() * 10); const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier); // Width buckets — 6 buckets cover the taper×pressure range const FLOW_WIDTH_BUCKETS = 6; type FlowSeg = { x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }; const flowBuckets: Array = []; for (let b = 0; b < FLOW_WIDTH_BUCKETS; b++) flowBuckets.push([]); // Track the representative width for each bucket const flowBucketWidths: number[] = new Array(FLOW_WIDTH_BUCKETS); // Pre-compute max possible width for bucket assignment let globalMaxFlowWidth = 0; for (let i = 0; i < numFlowLines; i++) { let fx = rng() * width; let fy = rng() * height; const steps = 30 + Math.floor(rng() * 40); const stepLen = (3 + rng() * 5) * scaleFactor; const startWidth = (1 + rng() * 3) * scaleFactor; if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth; // Variable color: interpolate between two hierarchy colors along the stroke const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum); const lineColorEnd = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum); const lineAlpha = 0.06 + rng() * 0.1; // Pressure simulation: sinusoidal width variation const pressureFreq = 2 + rng() * 4; const pressurePhase = rng() * Math.PI * 2; let prevX = fx; let prevY = fy; for (let s = 0; s < steps; s++) { const angle = flowAngle(fx, fy) + (rng() - 0.5) * 0.3; fx += Math.cos(angle) * stepLen; fy += Math.sin(angle) * stepLen; if (fx < 0 || fx > width || fy < 0 || fy > height) break; // Skip segments that pass through void zones if (isInVoidZone(fx, fy, voidZones)) { prevX = fx; prevY = fy; continue; } const t = s / steps; const taper = 1 - t * 0.8; const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase); const segWidth = startWidth * taper * pressure; const segAlpha = lineAlpha * taper; const lineColor = t < 0.5 ? hexWithAlpha(lineColorStart, 0.4 + t * 0.2) : hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2); // Quantize width into bucket const bucketIdx = Math.min( FLOW_WIDTH_BUCKETS - 1, Math.floor((segWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS), ); flowBuckets[bucketIdx].push({ x1: prevX, y1: prevY, x2: fx, y2: fy, color: lineColor, alpha: segAlpha }); flowBucketWidths[bucketIdx] = segWidth; // Branching: ~12% chance per step to spawn a thinner child stroke if (rng() < 0.12 && s > 5 && s < steps - 10) { const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5); let bx = fx; let by = fy; let bPrevX = fx; let bPrevY = fy; const branchSteps = 5 + Math.floor(rng() * 10); const branchWidth = startWidth * taper * 0.4; for (let bs = 0; bs < branchSteps; bs++) { const bAngle = branchAngle + (rng() - 0.5) * 0.2; bx += Math.cos(bAngle) * stepLen * 0.8; by += Math.sin(bAngle) * stepLen * 0.8; if (bx < 0 || bx > width || by < 0 || by > height) break; const bTaper = 1 - (bs / branchSteps) * 0.9; const bSegWidth = branchWidth * bTaper; const bAlpha = lineAlpha * taper * bTaper * 0.6; const bBucket = Math.min( FLOW_WIDTH_BUCKETS - 1, Math.floor((bSegWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS), ); flowBuckets[bBucket].push({ x1: bPrevX, y1: bPrevY, x2: bx, y2: by, color: lineColor, alpha: bAlpha }); flowBucketWidths[bBucket] = bSegWidth; bPrevX = bx; bPrevY = by; } } prevX = fx; prevY = fy; } } // Render flow line buckets — one batched path per width bucket // Within each bucket, further sub-batch by quantized alpha (4 levels) ctx.lineCap = "round"; const FLOW_ALPHA_BUCKETS = 4; for (let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++) { const segs = flowBuckets[wb]; if (segs.length === 0) continue; ctx.lineWidth = flowBucketWidths[wb]; // Sub-bucket by alpha const alphaSubs: FlowSeg[][] = []; for (let a = 0; a < FLOW_ALPHA_BUCKETS; a++) alphaSubs.push([]); let maxAlpha = 0; for (let j = 0; j < segs.length; j++) { if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha; } for (let j = 0; j < segs.length; j++) { const ai = Math.min( FLOW_ALPHA_BUCKETS - 1, Math.floor((segs[j].alpha / (maxAlpha || 1)) * FLOW_ALPHA_BUCKETS), ); alphaSubs[ai].push(segs[j]); } for (let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++) { const sub = alphaSubs[ai]; if (sub.length === 0) continue; // Use the median segment's alpha and color as representative const rep = sub[Math.floor(sub.length / 2)]; ctx.globalAlpha = rep.alpha; ctx.strokeStyle = rep.color; ctx.beginPath(); for (let j = 0; j < sub.length; j++) { ctx.moveTo(sub[j].x1, sub[j].y1); ctx.lineTo(sub[j].x2, sub[j].y2); } ctx.stroke(); } } _mark("6_flow_lines"); // ── 6b. Motion/energy lines — short directional bursts ───────── // Optimized: collect all burst segments, then batch by quantized alpha const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"]; const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25; if (hasEnergyLines && shapePositions.length > 0) { const energyCount = 5 + Math.floor(rng() * 10); ctx.lineCap = "round"; // Collect all energy segments with their computed state const ENERGY_ALPHA_BUCKETS = 3; const energyBuckets: Array> = []; for (let b = 0; b < ENERGY_ALPHA_BUCKETS; b++) energyBuckets.push([]); const energyAlphas: number[] = new Array(ENERGY_ALPHA_BUCKETS).fill(0); for (let e = 0; e < energyCount; e++) { const source = shapePositions[Math.floor(rng() * shapePositions.length)]; const burstCount = 2 + Math.floor(rng() * 4); const baseAngle = flowAngle(source.x, source.y); for (let b = 0; b < burstCount; b++) { const angle = baseAngle + (rng() - 0.5) * 1.2; const lineLen = (source.size * 0.3 + rng() * source.size * 0.5) * scaleFactor * 0.3; const startDist = source.size * 0.5; const sx = source.x + Math.cos(angle) * startDist; const sy = source.y + Math.sin(angle) * startDist; const ex = sx + Math.cos(angle) * lineLen; const ey = sy + Math.sin(angle) * lineLen; const eAlpha = 0.04 + rng() * 0.06; const eColor = hexWithAlpha( enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3, ); const eLw = (0.5 + rng() * 1.5) * scaleFactor; // Quantize alpha into bucket const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS)); energyBuckets[bi].push({ x1: sx, y1: sy, x2: ex, y2: ey, color: eColor, lw: eLw }); energyAlphas[bi] = eAlpha; } } // Render batched energy lines for (let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++) { const segs = energyBuckets[bi]; if (segs.length === 0) continue; ctx.globalAlpha = energyAlphas[bi]; // Use median segment's color and width as representative const rep = segs[Math.floor(segs.length / 2)]; ctx.strokeStyle = rep.color; ctx.lineWidth = rep.lw; ctx.beginPath(); for (let j = 0; j < segs.length; j++) { ctx.moveTo(segs[j].x1, segs[j].y1); ctx.lineTo(segs[j].x2, segs[j].y2); } ctx.stroke(); } } _mark("6b_energy_lines"); // ── 6c. Apply symmetry mirroring ───────────────────────────────── if (symmetryMode !== "none") { const canvas = ctx.canvas; ctx.save(); if (symmetryMode === "bilateral-x" || symmetryMode === "quad") { ctx.save(); ctx.translate(width, 0); ctx.scale(-1, 1); ctx.drawImage(canvas, 0, 0, Math.ceil(cx), height, 0, 0, Math.ceil(cx), height); ctx.restore(); } if (symmetryMode === "bilateral-y" || symmetryMode === "quad") { ctx.save(); ctx.translate(0, height); ctx.scale(1, -1); ctx.drawImage(canvas, 0, 0, width, Math.ceil(cy), 0, 0, width, Math.ceil(cy)); ctx.restore(); } ctx.restore(); } _mark("6c_symmetry"); // ── 7. Noise texture overlay ───────────────────────────────────── // With density capped at 2500 dots, direct fillRect calls are far cheaper // than the getImageData/putImageData round-trip which copies the entire // pixel buffer (4 × width × height bytes) twice. const noiseRng = createRng(seedFromHash(gitHash, 777)); const rawNoiseDensity = Math.floor((width * height) / 800); const noiseDensity = Math.min(rawNoiseDensity, 2500); const pixelScale = Math.max(1, Math.round(scaleFactor)); for (let i = 0; i < noiseDensity; i++) { const nx = noiseRng() * width; const ny = noiseRng() * height; const brightness = noiseRng() > 0.5 ? 255 : 0; const alpha = 0.01 + noiseRng() * 0.03; ctx.globalAlpha = alpha; ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`; ctx.fillRect(nx, ny, pixelScale, pixelScale); } _mark("7_noise_texture"); // ── 8. Vignette — darken edges to draw the eye inward ─────────── ctx.globalAlpha = 1; const vignetteStrength = 0.25 + rng() * 0.2; const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius); // Tint vignette based on background: warm sepia for light, cool blue for dark const isLightBg = bgLum > 0.5; const vignetteColor = isLightBg ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark vigGrad.addColorStop(0, "rgba(0,0,0,0)"); vigGrad.addColorStop(0.6, "rgba(0,0,0,0)"); vigGrad.addColorStop(1, vignetteColor); ctx.fillStyle = vigGrad; ctx.fillRect(0, 0, width, height); _mark("8_vignette"); // ── 9. Organic connecting curves — proximity-aware ─────────────── // Optimized: batch all curves into alpha-quantized groups to reduce // beginPath/stroke calls from O(numCurves) to O(alphaBuckets). if (shapePositions.length > 1) { const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024)); const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes ctx.lineWidth = 0.8 * scaleFactor; // Collect curves into 3 alpha buckets const CURVE_ALPHA_BUCKETS = 3; const curveBuckets: Array> = []; const curveColors: string[] = []; const curveAlphas: number[] = new Array(CURVE_ALPHA_BUCKETS).fill(0); for (let b = 0; b < CURVE_ALPHA_BUCKETS; b++) curveBuckets.push([]); for (let i = 0; i < numCurves; i++) { const idxA = Math.floor(rng() * shapePositions.length); const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1)); const idxB = (idxA + offset) % shapePositions.length; const a = shapePositions[idxA]; const b = shapePositions[idxB]; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.hypot(dx, dy); // Skip connections between distant shapes if (dist > maxCurveDist) { continue; } const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const bulge = (rng() - 0.5) * dist * 0.4; const cpx = mx + (-dy / (dist || 1)) * bulge; const cpy = my + (dx / (dist || 1)) * bulge; const curveAlpha = 0.06 + rng() * 0.1; const curveColor = hexWithAlpha( enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3, ); const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS)); curveBuckets[bi].push({ ax: a.x, ay: a.y, cpx, cpy, bx: b.x, by: b.y }); curveAlphas[bi] = curveAlpha; if (!curveColors[bi]) curveColors[bi] = curveColor; } // Render batched curves for (let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++) { const curves = curveBuckets[bi]; if (curves.length === 0) continue; ctx.globalAlpha = curveAlphas[bi]; ctx.strokeStyle = curveColors[bi]; ctx.beginPath(); for (let j = 0; j < curves.length; j++) { const c = curves[j]; ctx.moveTo(c.ax, c.ay); ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by); } ctx.stroke(); } } _mark("9_connecting_curves"); // ── 10. Post-processing ──────────────────────────────────────── // 10a. Color grading — unified tone across the whole image // Apply as a semi-transparent overlay in the grade hue ctx.globalAlpha = colorGrade.intensity * 0.25; ctx.globalCompositeOperation = "soft-light"; const gradeHsl = `hsl(${Math.round(colorGrade.hue)}, 40%, 50%)`; ctx.fillStyle = gradeHsl; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "source-over"; // 10b. Chromatic aberration — subtle RGB channel offset at edges // Only apply for neon/cosmic/ethereal archetypes where it fits const chromaArchetypes = ["neon-glow", "cosmic", "ethereal"]; if (chromaArchetypes.includes(archetype.name)) { const chromaOffset = Math.ceil(2 * scaleFactor); const canvas = ctx.canvas; // Shift red channel slightly ctx.globalAlpha = 0.03; ctx.globalCompositeOperation = "screen"; ctx.drawImage(canvas, chromaOffset, 0, width, height, 0, 0, width, height); // Shift blue channel opposite ctx.drawImage(canvas, -chromaOffset, 0, width, height, 0, 0, width, height); ctx.globalCompositeOperation = "source-over"; } // 10c. Bloom — soft glow on bright areas for neon/cosmic archetypes const bloomArchetypes = ["neon-glow", "cosmic"]; if (bloomArchetypes.includes(archetype.name)) { const canvas = ctx.canvas; ctx.globalAlpha = 0.08; ctx.globalCompositeOperation = "screen"; // Draw the image slightly scaled up and blurred via shadow ctx.save(); ctx.shadowBlur = 30 * scaleFactor; ctx.shadowColor = "rgba(255,255,255,0.3)"; ctx.drawImage(canvas, 0, 0, width, height); ctx.restore(); ctx.globalCompositeOperation = "source-over"; } // 10d. Gradient map — map luminance through a two-color gradient // Uses dominant→accent as the dark→light ramp for a cohesive tonal look if (rng() < 0.35) { const gmDark = colorHierarchy.dominant; const gmLight = colorHierarchy.accent; ctx.globalAlpha = 0.06 + rng() * 0.06; // very subtle: 6-12% ctx.globalCompositeOperation = "color"; // Paint a linear gradient from dark color (top) to light color (bottom) const gmGrad = ctx.createLinearGradient(0, 0, 0, height); gmGrad.addColorStop(0, gmDark); gmGrad.addColorStop(1, gmLight); ctx.fillStyle = gmGrad; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "source-over"; } _mark("10_post_processing"); // ── 10e. Generative borders — archetype-driven decorative frames ── { ctx.save(); ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; const borderRng = createRng(seedFromHash(gitHash, 314)); const borderPad = Math.min(width, height) * 0.025; const borderColor = hexWithAlpha(colorHierarchy.accent, 0.2); const borderColorSolid = colorHierarchy.accent; const archName = archetype.name; if (archName.includes("geometric") || archName.includes("op-art") || archName.includes("shattered")) { // Clean ruled lines with corner ornaments ctx.strokeStyle = borderColor; ctx.lineWidth = Math.max(1, 1.5 * scaleFactor); ctx.globalAlpha = 0.18 + borderRng() * 0.1; // Outer rule ctx.strokeRect(borderPad, borderPad, width - borderPad * 2, height - borderPad * 2); // Inner rule (thinner, offset) const innerPad = borderPad * 1.8; ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor); ctx.globalAlpha *= 0.7; ctx.strokeRect(innerPad, innerPad, width - innerPad * 2, height - innerPad * 2); // Corner ornaments — small squares at each corner const ornSize = borderPad * 0.6; ctx.fillStyle = hexWithAlpha(borderColorSolid, 0.12); const corners = [ [borderPad, borderPad], [width - borderPad - ornSize, borderPad], [borderPad, height - borderPad - ornSize], [width - borderPad - ornSize, height - borderPad - ornSize], ]; for (const [cx2, cy2] of corners) { ctx.fillRect(cx2, cy2, ornSize, ornSize); // Diagonal cross inside ornament ctx.beginPath(); ctx.moveTo(cx2, cy2); ctx.lineTo(cx2 + ornSize, cy2 + ornSize); ctx.moveTo(cx2 + ornSize, cy2); ctx.lineTo(cx2, cy2 + ornSize); ctx.stroke(); } } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) { // Vine tendrils — organic curving lines along edges // Optimized: batch all tendrils into a single path ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15); ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor); ctx.globalAlpha = 0.12 + borderRng() * 0.08; ctx.lineCap = "round"; const tendrilCount = 8 + Math.floor(borderRng() * 8); ctx.beginPath(); const leafPositions: Array<{ x: number; y: number; r: number }> = []; for (let t = 0; t < tendrilCount; t++) { // Start from a random edge point const edge = Math.floor(borderRng() * 4); let tx: number, ty: number; if (edge === 0) { tx = borderRng() * width; ty = borderPad; } else if (edge === 1) { tx = borderRng() * width; ty = height - borderPad; } else if (edge === 2) { tx = borderPad; ty = borderRng() * height; } else { tx = width - borderPad; ty = borderRng() * height; } ctx.moveTo(tx, ty); const segs = 3 + Math.floor(borderRng() * 4); for (let s = 0; s < segs; s++) { const inward = borderPad * (1 + borderRng() * 2); // Curl inward from edge const cpx2 = tx + (borderRng() - 0.5) * borderPad * 4; const cpy2 = ty + (edge < 2 ? (edge === 0 ? inward : -inward) : 0); const cpx3 = tx + (edge >= 2 ? (edge === 2 ? inward : -inward) : (borderRng() - 0.5) * borderPad * 3); const cpy3 = ty + (borderRng() - 0.5) * borderPad * 3; tx = cpx3; ty = cpy3; ctx.quadraticCurveTo(cpx2, cpy2, tx, ty); } // Collect leaf positions for batch fill if (borderRng() < 0.6) { leafPositions.push({ x: tx, y: ty, r: borderPad * (0.15 + borderRng() * 0.2) }); } } ctx.stroke(); // Batch all leaf dots into a single fill if (leafPositions.length > 0) { ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08); ctx.beginPath(); for (const leaf of leafPositions) { ctx.moveTo(leaf.x + leaf.r, leaf.y); ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2); } ctx.fill(); } } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) { // Star-studded arcs along edges ctx.globalAlpha = 0.1 + borderRng() * 0.08; ctx.fillStyle = hexWithAlpha(colorHierarchy.accent, 0.2); ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.12); ctx.lineWidth = Math.max(0.5, 0.7 * scaleFactor); // Subtle arc along top and bottom ctx.beginPath(); ctx.arc(cx, -height * 0.3, height * 0.6, 0.3, Math.PI - 0.3); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3); ctx.stroke(); // Scatter small stars along the border region — batched into single path const starCount = 15 + Math.floor(borderRng() * 15); ctx.beginPath(); for (let s = 0; s < starCount; s++) { const edge = Math.floor(borderRng() * 4); let sx: number, sy: number; if (edge === 0) { sx = borderRng() * width; sy = borderPad * (0.5 + borderRng()); } else if (edge === 1) { sx = borderRng() * width; sy = height - borderPad * (0.5 + borderRng()); } else if (edge === 2) { sx = borderPad * (0.5 + borderRng()); sy = borderRng() * height; } else { sx = width - borderPad * (0.5 + borderRng()); sy = borderRng() * height; } const starR = (1 + borderRng() * 2.5) * scaleFactor; // 4-point star for (let p = 0; p < 8; p++) { const a = (p / 8) * Math.PI * 2; const r = p % 2 === 0 ? starR : starR * 0.4; const px2 = sx + Math.cos(a) * r; const py2 = sy + Math.sin(a) * r; if (p === 0) ctx.moveTo(px2, py2); else ctx.lineTo(px2, py2); } ctx.closePath(); } ctx.fill(); } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) { // Thin single rule — understated elegance ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1); ctx.lineWidth = Math.max(0.5, 0.6 * scaleFactor); ctx.globalAlpha = 0.1 + borderRng() * 0.06; ctx.strokeRect(borderPad * 1.5, borderPad * 1.5, width - borderPad * 3, height - borderPad * 3); } // Other archetypes: no border (intentional — not every image needs one) ctx.restore(); } _mark("10e_borders"); // ── 11. Signature mark — placed in the least-dense corner ────── { const sigRng = createRng(seedFromHash(gitHash, 42)); const sigSize = Math.min(width, height) * 0.025; const sigMargin = sigSize * 2.5; // Find the corner with the lowest local density const cornerCandidates = [ { x: sigMargin, y: sigMargin }, // top-left { x: width - sigMargin, y: sigMargin }, // top-right { x: sigMargin, y: height - sigMargin }, // bottom-left { x: width - sigMargin, y: height - sigMargin }, // bottom-right ]; let bestCorner = cornerCandidates[3]; // default: bottom-right let minDensity = Infinity; for (const corner of cornerCandidates) { const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5); if (density < minDensity) { minDensity = density; bestCorner = corner; } } const sigX = bestCorner.x; const sigY = bestCorner.y; const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15); ctx.save(); ctx.globalAlpha = 0.12 + sigRng() * 0.08; ctx.translate(sigX, sigY); ctx.strokeStyle = sigColor; ctx.fillStyle = hexWithAlpha(colorHierarchy.dominant, 0.06); ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor); // Outer ring ctx.beginPath(); ctx.arc(0, 0, sigSize, 0, Math.PI * 2); ctx.stroke(); ctx.fill(); // Inner geometric pattern — unique per hash ctx.beginPath(); for (let s = 0; s < sigSegments; s++) { const angle1 = sigRng() * Math.PI * 2; const angle2 = sigRng() * Math.PI * 2; const r1 = sigSize * (0.2 + sigRng() * 0.6); const r2 = sigSize * (0.2 + sigRng() * 0.6); ctx.moveTo(Math.cos(angle1) * r1, Math.sin(angle1) * r1); ctx.lineTo(Math.cos(angle2) * r2, Math.sin(angle2) * r2); } ctx.stroke(); // Center dot ctx.beginPath(); ctx.arc(0, 0, sigSize * 0.12, 0, Math.PI * 2); ctx.fillStyle = sigColor; ctx.fill(); ctx.restore(); } ctx.globalAlpha = 1; _mark("11_signature"); // Clean up custom shape profiles to avoid leaking into subsequent renders for (const name of customShapeNames) { delete SHAPE_PROFILES[name]; } }