// L-System Tree
//
// A Lindenmayer system: start from an axiom string, iteratively rewrite via
// production rules, then interpret the final string as turtle-graphics commands.
// F/G = draw forward, +/- = turn, [ ] = push/pop state.
export const PARAMS = {
preset: { value: "Tree", options: ["Tree", "Bush", "Plant", "Dragon", "Sierpinski"], label: "Grammar Preset", folder: "Structure", rebuildOnChange: true },
iterations: { value: 5, min: 1, max: 8, step: 1, label: "Iterations", folder: "Structure", rebuildOnChange: true },
angle: { value: 25, min: 5, max: 90, step: 0.5, label: "Branch Angle (°)", folder: "Structure" },
segLength: { value: 8, min: 1, max: 30, step: 0.5, label: "Segment Length", folder: "Structure" },
hue: { value: 120, min: 0, max: 360, step: 1, label: "Trunk Hue", folder: "Appearance" },
hueShift: { value: 0.15, min: 0, max: 0.5, step: 0.01, label: "Hue Shift Per Level", folder: "Appearance" },
strokeW: { value: 1.5, min: 0.5, max: 5, step: 0.1, label: "Stroke Width", folder: "Appearance" },
bg: { value: "#0a0a0a", type: "color", label: "Background", folder: "Appearance" },
};
// ── Grammar definitions ──────────────────────────────────────────────────────
const GRAMMARS = {
Tree: {
axiom: "F",
rules: { F: "FF+[+F-F-F]-[-F+F+F]" },
startAngle: -90, // grows upward
defaultAngle: 25,
defaultIter: 5,
},
Bush: {
axiom: "Y",
rules: { X: "X[-FFF][+FFF]FX", Y: "YFX[+Y][-Y]" },
startAngle: -90,
defaultAngle: 25,
defaultIter: 5,
},
Plant: {
axiom: "X",
rules: { X: "F+[[X]-X]-F[-FX]+X", F: "FF" },
startAngle: -90,
defaultAngle: 25,
defaultIter: 5,
},
Dragon: {
axiom: "FX",
rules: { X: "X+YF+", Y: "-FX-Y" },
startAngle: 0,
defaultAngle: 90,
defaultIter: 10,
},
Sierpinski: {
axiom: "F-G-G",
rules: { F: "F-G+F+G-F", G: "GG" },
startAngle: 0,
defaultAngle: 120,
defaultIter: 6,
},
};
// ── L-system expansion ───────────────────────────────────────────────────────
function expand(axiom, rules, iters) {
let s = axiom;
for (let i = 0; i < iters; i++) {
let next = "";
for (const ch of s) {
next += rules[ch] ?? ch;
}
s = next;
// Safety: cap string length to avoid freezing
if (s.length > 300000) break;
}
return s;
}
// ── Bounding box pass (no drawing) ──────────────────────────────────────────
function measureBounds(str, startX, startY, startAngle, angleDeg, segLen) {
let x = startX, y = startY;
let angle = startAngle * Math.PI / 180;
const stack = [];
let minX = x, maxX = x, minY = y, maxY = y;
const ang = angleDeg * Math.PI / 180;
for (const ch of str) {
switch (ch) {
case "F": case "G": case "f": case "g": {
x += Math.cos(angle) * segLen;
y += Math.sin(angle) * segLen;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
break;
}
case "+": angle += ang; break;
case "-": angle -= ang; break;
case "[": stack.push([x, y, angle]); break;
case "]": { const s = stack.pop(); if (s) { [x, y, angle] = s; } break; }
}
}
return { minX, maxX, minY, maxY };
}
// ── HSL helper ───────────────────────────────────────────────────────────────
function hslStr(h, s, l) {
return `hsl(${((h % 360) + 360) % 360},${Math.round(s * 100)}%,${Math.round(l * 100)}%)`;
}
// ── Draw ─────────────────────────────────────────────────────────────────────
export function sketchSetup(ctx, w, h, tng) {
return {};
}
export function sketchDraw(ctx, w, h, params, tng) {
// Background
ctx.fillStyle = params.bg;
ctx.fillRect(0, 0, w, h);
const grammar = GRAMMARS[params.preset] ?? GRAMMARS["Tree"];
const angleDeg = params.angle;
const segLen = params.segLength;
// Expand
const iters = params.iterations | 0;
const str = expand(grammar.axiom, grammar.rules, iters);
// Measure bounds using a dummy origin
const bounds = measureBounds(str, 0, 0, grammar.startAngle, angleDeg, segLen);
const bw = bounds.maxX - bounds.minX;
const bh = bounds.maxY - bounds.minY;
// Fit to canvas with padding
const pad = 0.05;
const fitScale = Math.min(
(w * (1 - pad * 2)) / (bw || 1),
(h * (1 - pad * 2)) / (bh || 1),
);
// Start position: translate so the bounding box is centered
const startX = w / 2 - (bounds.minX + bw / 2) * fitScale;
const startY = h / 2 - (bounds.minY + bh / 2) * fitScale;
// Draw pass
let x = startX, y = startY;
let angle = grammar.startAngle * Math.PI / 180;
const ang = angleDeg * Math.PI / 180;
const stack = [];
let depth = 0;
ctx.lineCap = "round";
for (const ch of str) {
switch (ch) {
case "F": case "G": {
const nx = x + Math.cos(angle) * segLen * fitScale;
const ny = y + Math.sin(angle) * segLen * fitScale;
const depthHue = params.hue + depth * params.hueShift * 360;
const lightness = 0.35 + depth * 0.02;
ctx.strokeStyle = hslStr(depthHue, 0.6, Math.min(0.7, lightness));
ctx.lineWidth = Math.max(0.5, params.strokeW * Math.max(0.3, 1 - depth * 0.05));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
x = nx; y = ny;
break;
}
case "f": case "g": {
// move without drawing
x += Math.cos(angle) * segLen * fitScale;
y += Math.sin(angle) * segLen * fitScale;
break;
}
case "+": angle += ang; break;
case "-": angle -= ang; break;
case "[": stack.push([x, y, angle, depth]); depth++; break;
case "]": {
const s = stack.pop();
if (s) { [x, y, angle, depth] = s; }
break;
}
}
}
}