Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const Maze = lazy(() => import("./pages/Maze"));
const Fourier = lazy(() => import("./pages/Fourier"));
const Morse = lazy(() => import("./pages/Morse"));
const Galton = lazy(() => import("./pages/Galton"));
const Lsystem = lazy(() => import("./pages/Lsystem"));
const NotFound = lazy(() => import("./pages/NotFound"));

const App = () => (
Expand Down Expand Up @@ -494,6 +495,19 @@ const App = () => (
</>
}
/>
<Route
path="/lsystem"
element={
<>
<SEO
title="L-System Garden — fractal plants from rewrite rules"
path="/lsystem"
description="Grow ferns, dragon curves, and Koch snowflakes from L-system rewrite rules with turtle graphics — tune depth and branching angle live."
/>
<Lsystem />
</>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route
path="*"
Expand Down
157 changes: 157 additions & 0 deletions src/features/lsystem/turtle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
export interface LSystem {
axiom: string;
rules: Record<string, string>;
angle: number; // degrees
startAngle: number; // initial heading, degrees (0 = up)
iterations: number; // default depth
maxIterations: number;
stepShrink: number; // step length multiplier per '<' — 1 for none
}

export type PresetId = "plant" | "dragon" | "sierpinski" | "koch" | "bush";

export const PRESETS: Record<PresetId, { label: string; system: LSystem }> = {
plant: {
label: "Fern plant",
system: {
axiom: "X",
rules: { X: "F+[[X]-X]-F[-FX]+X", F: "FF" },
angle: 25,
startAngle: 0,
iterations: 5,
maxIterations: 7,
stepShrink: 1,
},
},
dragon: {
label: "Dragon curve",
system: {
axiom: "FX",
rules: { X: "X+YF+", Y: "-FX-Y" },
angle: 90,
startAngle: 90,
iterations: 10,
maxIterations: 15,
stepShrink: 1,
},
},
sierpinski: {
label: "Sierpiński",
system: {
axiom: "F-G-G",
rules: { F: "F-G+F+G-F", G: "GG" },
angle: 120,
startAngle: 90,
iterations: 5,
maxIterations: 8,
stepShrink: 1,
},
},
koch: {
label: "Koch snowflake",
system: {
axiom: "F--F--F",
rules: { F: "F+F--F+F" },
angle: 60,
startAngle: 90,
iterations: 4,
maxIterations: 6,
stepShrink: 1,
},
},
bush: {
label: "Bush",
system: {
axiom: "F",
rules: { F: "FF+[+F-F-F]-[-F+F+F]" },
angle: 22.5,
startAngle: 0,
iterations: 4,
maxIterations: 5,
stepShrink: 1,
},
},
};

const EXPANSION_CAP = 400_000;

/** Expand the axiom `n` times (capped so deep dragons can't hang the tab). */
export function expand(system: LSystem, n: number): string {
let s = system.axiom;
for (let i = 0; i < n; i++) {
let next = "";
for (const ch of s) {
next += system.rules[ch] ?? ch;
if (next.length > EXPANSION_CAP) return next;
}
s = next;
}
return s;
}

export interface Segment {
x1: number;
y1: number;
x2: number;
y2: number;
depth: number; // bracket nesting depth when drawn (for color)
}

export interface Bounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}

/**
* Run the turtle over an expanded string. F and G draw, f moves,
* +/- turn, [ ] push/pop state. Returns segments in unit coordinates.
*/
export function trace(
s: string,
angleDeg: number,
startAngleDeg: number
): { segments: Segment[]; bounds: Bounds } {
const rad = (d: number) => (d * Math.PI) / 180;
const turn = rad(angleDeg);
let x = 0, y = 0;
// startAngle 0 means "up" on canvas => angle from +x axis is -90deg
let heading = rad(-90 + startAngleDeg);
const stack: { x: number; y: number; heading: number; depth: number }[] = [];
let depth = 0;
const segments: Segment[] = [];
const bounds: Bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0 };

const stretch = (nx: number, ny: number) => {
if (nx < bounds.minX) bounds.minX = nx;
if (ny < bounds.minY) bounds.minY = ny;
if (nx > bounds.maxX) bounds.maxX = nx;
if (ny > bounds.maxY) bounds.maxY = ny;
};

for (const ch of s) {
if (ch === "F" || ch === "G") {
const nx = x + Math.cos(heading);
const ny = y + Math.sin(heading);
segments.push({ x1: x, y1: y, x2: nx, y2: ny, depth });
x = nx; y = ny;
stretch(x, y);
} else if (ch === "f") {
x += Math.cos(heading);
y += Math.sin(heading);
stretch(x, y);
} else if (ch === "+") {
heading += turn;
} else if (ch === "-") {
heading -= turn;
} else if (ch === "[") {
stack.push({ x, y, heading, depth });
depth++;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; heading = st.heading; depth = st.depth; }
}
}
return { segments, bounds };
}
197 changes: 197 additions & 0 deletions src/pages/Lsystem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import {
PresetId,
PRESETS,
expand,
trace,
} from "../features/lsystem/turtle";

const PRESET_KEYS = Object.keys(PRESETS) as PresetId[];
const DRAW_PER_FRAME = 400;

export default function Lsystem() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawIdxRef = useRef(0);
const segmentsRef = useRef<ReturnType<typeof trace>["segments"]>([]);
const transformRef = useRef({ scale: 1, ox: 0, oy: 0 });

const [preset, setPreset] = useState<PresetId>("plant");
const [iterations, setIterations] = useState(PRESETS.plant.system.iterations);
const [angle, setAngle] = useState(PRESETS.plant.system.angle);
const [segmentCount, setSegmentCount] = useState(0);

const rebuild = useCallback((pr: PresetId, iters: number, ang: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;

const sys = PRESETS[pr].system;
const expanded = expand(sys, iters);
const { segments, bounds } = trace(expanded, ang, sys.startAngle);
segmentsRef.current = segments;
setSegmentCount(segments.length);
drawIdxRef.current = 0;

// Fit bounds into canvas with padding
const W = canvas.width, H = canvas.height;
const bw = Math.max(1e-6, bounds.maxX - bounds.minX);
const bh = Math.max(1e-6, bounds.maxY - bounds.minY);
const scale = Math.min((W - 60) / bw, (H - 60) / bh);
transformRef.current = {
scale,
ox: (W - bw * scale) / 2 - bounds.minX * scale,
oy: (H - bh * scale) / 2 - bounds.minY * scale,
};

ctx.fillStyle = "#000";
ctx.fillRect(0, 0, W, H);
}, []);

// Progressive draw loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;

let raf = 0;
const loop = () => {
raf = requestAnimationFrame(loop);
const segments = segmentsRef.current;
const i0 = drawIdxRef.current;
if (i0 >= segments.length) return;
const { scale, ox, oy } = transformRef.current;
const i1 = Math.min(segments.length, i0 + DRAW_PER_FRAME);

for (let i = i0; i < i1; i++) {
const s = segments[i];
// Deeper branches render thinner and dimmer
const alpha = Math.max(0.25, 0.9 - s.depth * 0.08);
ctx.strokeStyle = `rgba(0,255,65,${alpha.toFixed(2)})`;
ctx.lineWidth = Math.max(0.5, 1.6 - s.depth * 0.15);
ctx.beginPath();
ctx.moveTo(s.x1 * scale + ox, s.y1 * scale + oy);
ctx.lineTo(s.x2 * scale + ox, s.y2 * scale + oy);
ctx.stroke();
}
drawIdxRef.current = i1;
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);

// Init + resize
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const rect = canvas.parentElement!.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
rebuild(preset, iterations, angle);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rebuild]);

const applyPreset = (pr: PresetId) => {
const sys = PRESETS[pr].system;
setPreset(pr);
setIterations(sys.iterations);
setAngle(sys.angle);
rebuild(pr, sys.iterations, sys.angle);
};

const sys = PRESETS[preset].system;

return (
<div className="min-h-screen bg-background text-primary font-mono flex flex-col">
{/* Header */}
<div className="border-b border-primary/20 px-4 py-3 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<Link to="/" className="text-primary/50 hover:text-primary text-sm transition-colors">
← home
</Link>
<span className="text-primary/20">|</span>
<span className="text-sm">l-system garden</span>
</div>
<div className="text-xs text-primary/40 tabular-nums">
{segmentCount.toLocaleString()} segments
</div>
</div>

{/* Controls */}
<div className="border-b border-primary/10 px-4 py-2 flex flex-wrap items-center gap-4">
<div className="flex gap-1 flex-wrap">
{PRESET_KEYS.map((pr) => (
<button
key={pr}
onClick={() => applyPreset(pr)}
className={`px-2 py-0.5 text-xs border transition-colors ${
preset === pr
? "border-primary bg-primary/10 text-primary"
: "border-primary/20 text-primary/40 hover:border-primary/50 hover:text-primary/70"
}`}
>
{PRESETS[pr].label}
</button>
))}
</div>

<div className="flex items-center gap-2">
<span className="text-primary/40 text-xs">depth</span>
<input
type="range"
min={1}
max={sys.maxIterations}
value={iterations}
onChange={(e) => {
const v = Number(e.target.value);
setIterations(v);
rebuild(preset, v, angle);
}}
className="w-24 accent-primary"
/>
<span className="text-primary/60 text-xs w-4">{iterations}</span>
</div>

<div className="flex items-center gap-2">
<span className="text-primary/40 text-xs">angle</span>
<input
type="range"
min={5}
max={140}
step={1}
value={angle}
onChange={(e) => {
const v = Number(e.target.value);
setAngle(v);
rebuild(preset, iterations, v);
}}
className="w-24 accent-primary"
/>
<span className="text-primary/60 text-xs w-8">{angle}°</span>
</div>

<button
onClick={() => rebuild(preset, iterations, angle)}
className="ml-auto px-3 py-1 text-xs border border-primary/30 hover:border-primary text-primary/70 hover:text-primary transition-colors"
>
↺ redraw
</button>
</div>

{/* Canvas */}
<div className="flex-1 relative overflow-hidden" style={{ minHeight: 0 }}>
<canvas ref={canvasRef} className="block w-full h-full" />
<div className="absolute bottom-3 left-4 text-xs text-primary/30 pointer-events-none">
{sys.axiom} → {Object.entries(sys.rules).map(([k, v]) => `${k}:${v}`).join(" ")} · drag the angle off its natural value and watch the form mutate
</div>
</div>
</div>
);
}
Loading