From 91c7d407f1a941422069493781b8baa9e6c3d57b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 04:37:41 +0000 Subject: [PATCH] Add l-system garden at /lsystem Rewrite-rule expansion (capped at 400k symbols so deep dragon curves cannot hang the tab) plus a turtle interpreter supporting F/G draw, f move, +/- turns, and [ ] state stack with bracket depth tracked per segment. Deeper branches render thinner and dimmer for a natural plant look. Five presets: fern plant, dragon curve, Sierpinski triangle, Koch snowflake, and bush; depth and angle sliders rebuild live (the angle slider mutates the form away from the canonical value). Segments draw progressively at 400/frame; the figure auto-fits its bounds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WgEKRuVdor8KhaiPkv38aQ --- src/App.tsx | 14 +++ src/features/lsystem/turtle.ts | 157 ++++++++++++++++++++++++++ src/pages/Lsystem.tsx | 197 +++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 src/features/lsystem/turtle.ts create mode 100644 src/pages/Lsystem.tsx diff --git a/src/App.tsx b/src/App.tsx index 1e98754..2200412 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ const Toolkit = lazy(() => import("./pages/Toolkit")); const Demos = lazy(() => import("./pages/Demos")); const Bots = lazy(() => import("./pages/Bots")); const Kelly = lazy(() => import("./pages/Kelly")); +const Lsystem = lazy(() => import("./pages/Lsystem")); const NotFound = lazy(() => import("./pages/NotFound")); const App = () => ( @@ -102,6 +103,19 @@ const App = () => ( } /> + + + + + } + /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} ; + 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 = { + 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 }; +} diff --git a/src/pages/Lsystem.tsx b/src/pages/Lsystem.tsx new file mode 100644 index 0000000..7c39d64 --- /dev/null +++ b/src/pages/Lsystem.tsx @@ -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(null); + const drawIdxRef = useRef(0); + const segmentsRef = useRef["segments"]>([]); + const transformRef = useRef({ scale: 1, ox: 0, oy: 0 }); + + const [preset, setPreset] = useState("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 ( +
+ {/* Header */} +
+
+ + ← home + + | + l-system garden +
+
+ {segmentCount.toLocaleString()} segments +
+
+ + {/* Controls */} +
+
+ {PRESET_KEYS.map((pr) => ( + + ))} +
+ +
+ depth + { + const v = Number(e.target.value); + setIterations(v); + rebuild(preset, v, angle); + }} + className="w-24 accent-primary" + /> + {iterations} +
+ +
+ angle + { + const v = Number(e.target.value); + setAngle(v); + rebuild(preset, iterations, v); + }} + className="w-24 accent-primary" + /> + {angle}° +
+ + +
+ + {/* Canvas */} +
+ +
+ {sys.axiom} → {Object.entries(sys.rules).map(([k, v]) => `${k}:${v}`).join(" ")} · drag the angle off its natural value and watch the form mutate +
+
+
+ ); +}