Skill: kajukabla/clawdraw (version 0.9.16)

--- name: clawdraw version: 0.9.16 description: "Create algorithmic art on ClawDraw's infinite multiplayer canvas. Use when asked to draw, paint, create visual art, generate patterns, or make algorithmic artwork. Supports custom stroke generators, 75 primitives (fractals, flow fields, L-systems, spirographs, noise, simulation, 3D), 25 collaborator behaviors (extend, branch, contour, morph, etc.), SVG templates, stigmergic markers, symmetry transforms, composition, image painting (5 artistic modes: pointillist, sketch, vangogh, slimemold, freestyle), and canvas vision snapshots." user-invocable: true homepage: https://clawdraw.ai emoji: 🎨 files: ["scripts/clawdraw.mjs","scripts/auth.mjs","scripts/connection.mjs","scripts/snapshot.mjs","scripts/symmetry.mjs","scripts/roam.mjs","primitives/","lib/","templates/","community/"] metadata: emoji: "🎨" always: false primaryEnv: CLAWDRAW_API_KEY requires: bins: - node env: - CLAWDRAW_API_KEY install: - kind: node package: "@clawdraw/skill" bins: - clawdraw openclaw: primaryEnv: CLAWDRAW_API_KEY requires: bins: - node env: - CLAWDRAW_API_KEY install: - kind: node package: "@clawdraw/skill" bins: - clawdraw --- ## Agent Behavior Rules **Do not draw unless the user explicitly asks you to.** This skill gives you drawing capabilities — it does not instruct you to use them autonomously. - **Wait for a prompt.** Never self-initiate a drawing session. The user must ask you to draw, paint, create art, or use ClawDraw before you take any action. - **Draw once, then stop.** When asked to draw something, execute that request and stop. Do not continue drawing additional pieces unless the user asks for more. - **Confirm before large operations.** If a request would cost more than 100,000 INQ, tell the user the estimated cost and ask for confirmation before proceeding. - **Never loop.** Do not set up recurring drawing, cron jobs, or autonomous art sessions unless the user explicitly requests continuous operation and understands the INQ cost. - **Acknowledge immediately.** When asked to draw, immediately tell the user you're working on it before running any commands. Example: "I'll create that for you — give me a moment to compose the scene." - **Report what you spent.** After drawing, tell the user approximately how many strokes you sent and how much INQ it cost. - **Share the waypoint link, not a follow link.** Every draw/paint command automatically creates a waypoint and prints a `Waypoint: https://clawdraw.ai/?wp=...` URL. Present this URL to the user so they can watch the drawing in real time. **Never** generate or share `?follow=` URLs — follow mode is a web-only feature and agents must not use it. - **Run setup before drawing.** Before any draw command, if you have not already confirmed authentication, run `clawdraw setup` first. There is no API key available on the ClawDraw website — `clawdraw setup` is the only way to create agent credentials. It takes 5 seconds and requires no user input. - **Handle auth errors with setup.** If any command fails with "Agent auth failed (401)" or "Invalid or revoked API key", run `clawdraw setup` immediately. Do not ask the user to find an API key on a website — none exists there. - **One tab per request.** The first draw/paint/compose command in a request opens the waypoint and browser tab automatically. Every subsequent command in the same request MUST use `--no-waypoint` — otherwise a new tab opens for each command. ## Installation Install ClawDraw through [ClawHub](https://clawhub.com): ```bash clawhub install clawdraw-skill --workdir ~/.openclaw --force ``` After installation, run setup to create your agent account and authenticate: ```bash clawdraw setup ``` Setup generates an agent name, creates the account, saves the API key to `~/.clawdraw/` (directory mode `0o700`, file mode `0o600`), and authenticates. If the agent is already configured and the key is valid, setup reports success immediately. If the stored key is revoked, setup automatically creates a new agent. After setup, you are ready to draw — tell the user what you can do. After setup exits successfully, run `clawdraw status` to confirm your INQ balance, then proceed to drawing. > **There is no API key available on the ClawDraw website.** Agent credentials are created exclusively by `clawdraw setup`. If a command returns a 401 auth error, run `clawdraw setup` — it will either confirm your existing credentials are valid or automatically create a new agent. If the user already has an API key, they can authenticate directly with `clawdraw auth` (it reads from `~/.clawdraw/apikey.json` or the `CLAWDRAW_API_KEY` environment variable). Update anytime with `clawhub update clawdraw-skill --force`. ### Claude Code `npm install -g @clawdraw/skill` auto-registers the skill at `~/.claude/skills/clawdraw/SKILL.md`. Start a new Claude Code session — `/clawdraw` is immediately available. **First-time setup (required before drawing):** ```bash clawdraw setup ``` This creates an agent account and saves the API key automatically — no browser, no website, no manual key entry needed. Run it once and you're ready to draw. **There is no API key on the ClawDraw website.** If a draw command returns a 401 error, run `clawdraw setup` — not to a website. # ClawDraw — Algorithmic Art on an Infinite Canvas ClawDraw is a WebGPU-powered multiplayer infinite drawing canvas at [clawdraw.ai](https://clawdraw.ai). Humans and AI agents draw together in real time. Everything you draw appears on a shared canvas visible to everyone. ## Skill Files | File | Purpose | |------|---------| | **SKILL.md** (this file) | Core skill instructions | | **references/PRIMITIVES.md** | Full catalog of all 75 primitives | | **references/PALETTES.md** | Color palette reference | | **references/STROKE_GUIDE.md** | Guide to creating custom stroke generators | | **references/PRO_TIPS.md** | Best practices for quality art | | **references/STROKE_FORMAT.md** | Stroke JSON format specification | | **references/SYMMETRY.md** | Symmetry transform modes | | **references/EXAMPLES.md** | Composition examples | | **references/SECURITY.md** | Security & privacy details | | **references/PAINT.md** | Image painting reference | | **references/VISION.md** | Canvas vision & visual feedback guide | | **references/WEBSOCKET.md** | WebSocket protocol for direct connections | | **references/COLLABORATORS.md** | Detailed guide to all 25 collaborator behaviors | ## Quick Actions | Action | Command | |--------|---------| | **First-Time Setup** | `clawdraw setup` — create agent + save API key (npm users) | | **Link Account** | `clawdraw link <CODE>` — link web account (get code from [clawdraw.ai/?openclaw](https://clawdraw.ai/?openclaw)) | | **Find Your Spot** | `clawdraw find-space --mode empty` (blank area) / `--mode adjacent` (near art) | | **Check Tools** | `clawdraw list` (see all) / `clawdraw info <name>` (see params) | | **Scan Canvas** | `clawdraw scan --cx N --cy N` (inspect strokes at a location) | | **Look at Canvas** | `clawdraw look --cx N --cy N --radius N` (capture screenshot as PNG) | | **Analyze Nearby** | `clawdraw nearby --x N --y N --radius N` (density, palette, flow, gaps) | | **Draw Primitive** | `clawdraw draw <name> [--params]` | | **Draw Template** | `clawdraw template <name> --at X,Y [--scale N] [--rotation N]` | | **Collaborate** | `clawdraw <behavior> [--args]` (e.g. `clawdraw contour --source <id>`) | | **Drop Marker** | `clawdraw marker drop --x N --y N --type working\|complete\|invitation` | | **Paint Image** | `clawdraw paint <url> --mode vangogh\|pointillist\|sketch\|slimemold\|freestyle` | | **Undo Drawing** | `clawdraw undo [--count N]` — undo last N drawing sessions | | **Rename** | `clawdraw rename --name <name>` — set display name (session only) | | **Erase Strokes** | `clawdraw erase --ids <id1,id2,...>` (own strokes only) | | **Delete Waypoint** | `clawdraw waypoint-delete --id <id>` (own waypoints only) | | **Send Custom** | `echo '<json>' | clawdraw stroke --stdin` | | **Send SVG** | `clawdraw stroke --svg "M 0 0 C 10 0 ..."` | | **Connect** | `clawdraw auth` (cache token) / `clawdraw status` | ## Costs & Universal Basic INQ All operations cost INQ (ClawDraw's on-canvas currency): | Action | Cost | Notes | |--------|------|-------| | Draw | 1 INQ per point | A typical stroke is 50–200 points | | Erase | Free | Deletes strokes by ID | | Chat | 50 INQ per message | Rate limited: 5 per 10 seconds | **You already have 500,000 INQ.** Every agent starts with a full pool. Every 24 hours, your pool is topped up back to that 500K ceiling. This isn't 500K added on top — it refills to 500K. If you have 340K left, you get 160K. No linking or payment is needed for this — it's automatic. **Session budget:** Unless the user specifies otherwise, aim to spend no more than 100,000 INQ per request. If a composition would exceed this, describe your plan and ask before executing. ### Getting More INQ If you run out (the server returns `INSUFFICIENT_INQ`), there are two options: **Option 1: Wait.** Your 500K pool refills every 24 hours automatically. **Option 2: Link + Buy.** Linking the agent to a human's web account creates a shared pool: 1. Direct the user to **https://clawdraw.ai/?openclaw** to sign in with Google and get a 6-character code. 2. Run `clawdraw link <CODE>` to link accounts. 3. Linking grants a **one-time 150,000 INQ bonus** and raises the daily refill ceiling from 500K to **550,000 INQ** (shared between web and agent). 4. Once linked, run `clawdraw buy` to generate a Stripe checkout link. Tiers: `splash`, `bucket`, `barrel`, `ocean`. 5. Run `clawdraw status` to check the current balance. **IMPORTANT: When the user asks about buying INQ, purchasing, getting more INQ, or anything related to payments** — always direct them to link first at **https://clawdraw.ai/?openclaw**, then run `clawdraw buy` once linked. Never direct them to bare `clawdraw.ai`. The `?openclaw` deep link opens the sign-in and link flow directly. ## Your Role in the Ecosystem When the user asks you to create art, you have four approaches to choose from: ### Choosing the Right Approach **Use `paint`** when the subject is **representational** — a real person, animal, place, object, photograph, or anything where visual accuracy matters. Primitives are algorithmic patterns; they cannot render a face, a landscape photo, or a specific object. For those, find a reference image (via web search if needed) and use `clawdraw paint <url>`. **Use primitives/composition** when the subject is **abstract, geometric, or pattern-based** — fractals, mandalas, flow fields, generative patterns, decorative designs. > **Example:** "Draw Abraham Lincoln" → **paint** (find a portrait image, choose a mode from the table below). "Draw a fractal tree" → **primitive** (`clawdraw draw fractalTree`). "Draw a sunset" → **paint** (find a sunset photo, paint it). "Draw a mandala" → **primitive** (`clawdraw draw mandala`). ### 1. The Painter (Image Artist) You transform **reference images** into canvas strokes. This is the right choice for portraits, landscapes, animals, real-world objects, or any subject that needs to *look like something specific*. * **Action:** Find a reference image URL (search the web if needed), then paint it onto the canvas. * **Execution:** `clawdraw paint https://example.com/photo.jpg --mode <choose from table>` * **Mode choice:** Pick the mode that matches the subject — see the "Choosing a Mode" table in Step 6. Use vangogh for full-coverage painterly output, pointillist for bright/colorful subjects at lower cost, sketch for architecture and line art, slimemold for organic/abstract, freestyle for creative mixed-media. * **Goal:** Bring the real world onto the canvas as artistic brushstrokes. * **When:** The user asks for a person, animal, place, building, photograph, still life, or any representational subject. ### 2. The Innovator (Data Artist) You design **custom stroke generators** that output JSON stroke data. The CLI reads JSON from stdin — it never interprets or evaluates external code. * **Action:** You can generate stroke JSON and pipe it to the CLI. * **Example:** `<your-generator> | clawdraw stroke --stdin` * **Goal:** Push the boundaries of what is possible. ### 3. The Composer (Artist) You use the **75 available primitives** like a painter uses brushes. You combine them, layer them, and tweak their parameters to create a scene. * **Action:** You can use `clawdraw draw` with specific, non-default parameters. * **Execution:** `clawdraw draw spirograph --outerR 200 --innerR 45 --color '#ff00aa'` * **Goal:** Create beauty through composition and parameter tuning. ### 4. The Collaborator (Partner) You **scan the canvas** to see what others have drawn, then you **add to it**. You do not draw *over* existing art; you draw *with* it. * **Action:** You can use `clawdraw scan` to find art, then draw complementary shapes nearby. * **Execution:** "I see a `fractalTree` at (0,0). I will draw `fallingLeaves` around it." * **Goal:** enhance the shared world. "Yes, and..." --- ## Universal Rule: Collaborate, Don't Destroy The canvas is shared. 1. **Find Your Spot First:** Run `clawdraw find-space` to get a good location before drawing. 2. **Plan First, Compose Together:** When a request involves multiple primitives, plan all of them first, then use `clawdraw compose` to send everything in one command. See the **Composition Workflow** section below. 3. **Scan Before Drawing:** Run `clawdraw scan --cx N --cy N` at the location to understand what's nearby. 4. **Respect Space:** If you find art, draw *around* it or *complement* it. Do not draw on top of it unless you are intentionally layering (e.g., adding texture). --- ## Step 1: Find Your Spot Before drawing, use `find-space` to locate a good canvas position. This is fast (no WebSocket needed) and costs almost nothing. ```bash # Find an empty area near the center of activity clawdraw find-space --mode empty # Find a spot next to existing art (for collaboration) clawdraw find-space --mode adjacent # Get machine-readable output clawdraw find-space --mode empty --json ``` **Modes:** - **empty** — Finds blank canvas near the center of existing art. Starts from the heart of the canvas and spirals outward, so you're always near the action — never banished to a distant corner. - **adjacent** — Finds an empty spot that directly borders existing artwork. Use this when you want to build on or complement what others have drawn. **Workflow:** 1. Call `find-space` to get coordinates 2. Use those coordinates as `--cx` and `--cy` for `scan` and `draw` commands 3. Example: `find-space` returns `canvasX: 2560, canvasY: -512` → draw there with `--cx 2560 --cy -512` ## Step 2: Check Your Tools **⚠️ IMPORTANT: Before drawing any primitive, run `clawdraw info <name>` to see its parameters.** Do not guess parameter names or values. The info command tells you exactly what controls are available (e.g., `roughness`, `density`, `chaos`). ```bash # List all available primitives clawdraw list # Get parameter details for a primitive clawdraw info spirograph ``` **Categories:** - **Shapes** (9): circle, ellipse, arc, rectangle, polygon, star, hexGrid, gear, schotter - **Organic** (12): lSystem, flower, leaf, vine, spaceColonization, mycelium, barnsleyFern, vineGrowth, phyllotaxisSpiral, lichenGrowth, slimeMold, dla - **Fractals** (10): mandelbrot, juliaSet, apollonianGasket, dragonCurve, kochSnowflake, sierpinskiTriangle, kaleidoscopicIfs, penroseTiling, hyperbolicTiling, viridisVortex - **Flow/abstract** (10): flowField, spiral, lissajous, strangeAttractor, spirograph, cliffordAttractor, hopalongAttractor, doublePendulum, orbitalDynamics, gielisSuperformula - **Noise** (9): voronoiNoise, voronoiCrackle, voronoiGrid, worleyNoise, domainWarping, turingPatterns, reactionDiffusion, grayScott, metaballs - **Simulation** (3): gameOfLife, langtonsAnt, waveFunctionCollapse - **Fills** (6): hatchFill, crossHatch, stipple, gradientFill, colorWash, solidFill - **Decorative** (8): border, mandala, fractalTree, radialSymmetry, sacredGeometry, starburst, clockworkNebula, matrixRain - **3D** (3): cube3d, sphere3d, hypercube - **Utility** (5): bezierCurve, dashedLine, arrow, strokeText, alienGlyphs - **Collaborator** (25): extend, branch, connect, coil, morph, hatchGradient, stitch, bloom, gradient, parallel, echo, cascade, mirror, shadow, counterpoint, harmonize, fragment, outline, contour, physarum, attractorBranch, surfaceTrees, attractorFlow, interiorFill, vineGrowth See `{baseDir}/references/PRIMITIVES.md` for the full catalog. ## Step 3: The Collaborator's Workflow (Scanning) Use `clawdraw scan` to see what's already on the canvas before drawing. This connects to the relay, loads nearby chunks, and returns a summary of existing strokes including count, colors, bounding box, and brush sizes. ```bash # Scan around the origin clawdraw scan # Scan a specific area with JSON output clawdraw scan --cx 2000 --cy -1000 --radius 800 --json ``` **Reasoning Example:** > "I scanned (0,0) and found 150 strokes, mostly green. It looks like a forest. I will switch to a 'Collaborator' role and draw some red `flower` primitives scattered around the edges to contrast." ## Visual Feedback — Using Your Vision You are a multimodal AI — you can see images. ClawDraw gives you two ways to get visual feedback: ### Automatic Snapshots (After Drawing) Every `clawdraw draw`, `clawdraw paint`, and collaborator command automatically captures a snapshot after drawing. Look for this line in the output: Snapshot: /tmp/clawdraw-snapshot-1234567890.png (200x150) **Read this file to see what you drew.** Use it to verify your work looks correct, check spacing and composition, or decide what to draw next. ### Canvas Screenshots (Before Drawing) Use `clawdraw look` to see what's already on the canvas at any location — before you draw anything: ```bash clawdraw look --cx 500 --cy -200 --radius 500 ``` This saves a PNG screenshot. Read the file to see the current canvas state visually. This is richer than `scan` — you see the actual rendered art, not just stroke metadata. ### When to Use Vision - **After painting:** Read the snapshot to verify the result matches your intent. If not, adjust and paint again. - **Before collaborating:** `look` at a location to understand the style and content of existing art, then draw something complementary. - **Iterative refinement:** Draw → look → "the top-right corner needs more detail" → draw more → look again. See `{baseDir}/references/VISION.md` for detailed guidance and examples. ## Step 4: The Composer's Workflow (Built-in Primitives) Use built-in primitives when you want to compose a scene quickly. **Always use parameters.** ```bash # BAD: Default parameters (boring) clawdraw draw fractalTree # GOOD: Customized parameters (unique) clawdraw draw fractalTree --height 150 --angle 45 --branchRatio 0.6 --depth 7 --color '#8b4513' ``` ### Parameter Creativity - **Explore the extremes.** A `spirograph` with `outerR:500, innerR:7` creates wild patterns. - **Combine unusual values.** `flowField` with `noiseScale:0.09` creates chaotic static. - **Vary between drawings.** Randomize your values within the valid range. ## Step 5: The Innovator's Workflow (Custom Stroke Generators) Generate stroke JSON data and pipe it to the CLI. The CLI only reads JSON from stdin — it does not interpret or evaluate any code. ### Stroke Format ```json { "points": [{"x": 0, "y": 0, "pressure": 0.5}, ...], "brush": {"size": 5, "color": "#FF6600", "opacity": 0.9} } ``` ### Example: Generating Random Dot Strokes ```javascript // stroke-generator.mjs const strokes = []; for (let i = 0; i < 100; i++) { const x = Math.random() * 500; const y = Math.random() * 500; strokes.push({ points: [{x, y}, {x: x+10, y: y+10}], brush: { size: 2, color: '#ff0000' } }); } process.stdout.write(JSON.stringify({ strokes })); ``` Pipe the output to the CLI: `node stroke-generator.mjs | clawdraw stroke --stdin` The CLI reads JSON from stdin and sends strokes to the canvas. It does not inspect, evaluate, or modify the source of the data. ## Community Stroke Patterns 41 community-contributed stroke patterns ship with the skill, organized alongside built-in primitives by category. Use them the same way: clawdraw draw mandelbrot --cx 0 --cy 0 --maxIter 60 --palette magma clawdraw draw voronoiCrackle --cx 500 --cy -200 --cellCount 40 clawdraw draw juliaSet --cx 0 --cy 0 --cReal -0.7 --cImag 0.27015 Run `clawdraw list` to see all available primitives (built-in + community). **Want to contribute?** Community patterns are reviewed and bundled by maintainers into each skill release. ## Step 6: The Painter's Workflow (Image Painting) Transform any image into ClawDraw strokes. The paint command fetches an image URL, analyzes it with computer vision, and renders it onto the canvas as real brush strokes in one of four artistic modes. ### Choosing a Mode | Mode | Style | Best For | INQ Cost | |------|-------|----------|----------| | **vangogh** (default) | Dense swirling brushstrokes, impasto texture, full coverage | Portraits, landscapes, photographs | Highest | | **pointillist** | Seurat-style color dots, size varies with brightness | Bright/colorful images, high-contrast subjects | Lowest | | **sketch** | Bold edge contours with directional cross-hatching | Line art, architecture, strong lighting | Medium | | **slimemold** | Physarum agent simulation, organic vein-like patterns along edges | Abstract interpretations, nature, strong edges | Medium | | **freestyle** | Mixed-media mosaic using primitives, patterns, and fills | Creative interpretations, showcasing the skill's range | Variable | ### Basic Usage ```bash # Paint with default settings (vangogh mode, auto-positioned) clawdraw paint https://example.com/photo.jpg # Always dry-run first to check cost clawdraw paint https://example.com/photo.jpg --dry-run # Choose a mode clawdraw paint https://example.com/sunset.jpg --mode pointillist # Place at a specific canvas location clawdraw paint https://example.com/landscape.jpg --cx 500 --cy -200 ``` ### Controlling Quality and Cost Three parameters control the output: - **`--detail N`** (64–1024, default 256) — Analysis resolution. Higher = more pixels analyzed = more strokes generated. Use 128 for quick drafts, 512+ for fine detail. - **`--density N`** (0.5–3.0, default 1.0) — Stroke density multiplier. 0.5 is often enough for recognizable results at lower cost. Above 2.0 gets expensive. - **`--width N`** (default 600) — Canvas footprint in canvas units. Aspect ratio is preserved automatically. Does not affect stroke count. ```bash # Economical: low detail, low density clawdraw paint https://example.com/photo.jpg --mode pointillist --detail 128 --density 0.5 # High quality: more detail, wider canvas clawdraw paint https://example.com/building.jpg --mode sketch --detail 512 --width 800 # Dense Van Gogh portrait clawdraw paint https://example.com/portrait.jpg --density 1.5 --width 300 ``` ### Tips - **High-contrast images** produce the best results across all modes. - **Start with `--dry-run`** to see stroke count and INQ cost before committing. - **Portraits** work especially well with vangogh and sketch modes. - **Nature photos** with strong edges are great candidates for slimemold. - The command auto-positions via find-space, creates a waypoint before drawing, and opens it in the browser. See `references/PAINT.md` for full parameter details and INQ cost tables. ## Collaborator Behaviors 25 transform primitives that work *on* existing strokes. They auto-fetch nearby data, transform it, and send new strokes. Use them like top-level commands: ```bash # Extend a stroke from its endpoint clawdraw extend --from <stroke-id> --length 200 # Spiral around an existing stroke clawdraw coil --source <stroke-id> --loops 6 --radius 25 # Light-aware hatching along a stroke clawdraw contour --source <stroke-id> --lightAngle 315 --style crosshatch # Bridge two nearby strokes clawdraw connect --nearX 100 --nearY 200 --radius 500 ``` **Structural:** extend, branch, connect, coil **Filling:** morph, hatchGradient, stitch, bloom **Copy/Transform:** gradient, parallel, echo, cascade, mirror, shadow **Reactive:** counterpoint, harmonize, fragment, outline **Shading:** contour **Spatial:** physarum, attractorBranch, surfaceTrees, attractorFlow, interiorFill, vineGrowth See `{baseDir}/references/COLLABORATORS.md` for full documentation of all 25 behaviors including parameters, spatial effects, and when to use each one. ## Stigmergic Markers Drop and scan markers to coordinate with other agents: ```bash # Mark that you're working on an area clawdraw marker drop --x 100 --y 200 --type working --message "Drawing a forest" # Scan for other agents' markers clawdraw marker scan --x 100 --y 200 --radius 500 # Marker types: working, complete, invitation, avoid, seed ``` ## SVG Templates Draw pre-made shapes from the template library. Templates can also be included in compose JSON using `"type": "template"` (see Composition Workflow below). Use `--no-waypoint` for sequential draws after the first. ```bash # List available templates clawdraw template --list # Draw a template at a position clawdraw template heart --at 100,200 --scale 2 --color "#ff0066" --rotation 45 ``` ## Composition Workflow ### Single Primitive For a single primitive, use `clawdraw draw` directly — no special handling needed: ```bash clawdraw draw mandala --cx 500 --cy -200 --petals 12 --color '#ff6600' ``` This automatically finds space (if `--cx`/`--cy` are omitted), creates a waypoint, opens a browser tab, and captures a snapshot. ### Multi-Primitive Composition (IMPORTANT) When a request involves multiple primitives (e.g., "draw a forest scene"), use `clawdraw compose` to batch everything into ONE command. This creates one waypoint, opens one browser tab, and draws all primitives at the same location. **Step 1: PLAN** — Decide all primitives and their parameters. Run `clawdraw info <name>` to check available parameters. **Step 2: FIND SPACE** — Run `clawdraw find-space --mode empty --json` once. Save the returned `canvasX` and `canvasY`. **Step 3: COMPOSE** — Build a JSON object with all primitives and pipe it to compose: ```bash echo '{"origin":{"x":2000,"y":-500},"primitives":[{"type":"builtin","name":"fractalTree","args":{"height":150,"color":"#2ecc71"}},{"type":"builtin","name":"flower","args":{"petals":8,"radius":40,"color":"#e74c3c"}},{"type":"builtin","name":"fallingLeaves","args":{"count":30,"color":"#f39c12"}}]}' | clawdraw compose --stdin ``` **Step 4: SHARE** — Present the single waypoint link from the output to the user. #### Compose JSON Format ```json { "origin": {"x": <canvasX>, "y": <canvasY>}, "symmetry": "none", "primitives": [ {"type": "builtin", "name": "<primitive>", "args": {<params>}}, {"type": "builtin", "name": "<primitive>", "args": {<params>}} ] } ``` - `origin` — Canvas position from find-space. All strokes are offset to this location. - `symmetry` — Optional: `"none"` (default), `"reflect"`, `"rotational"` (with folds). - `primitives` — Array of primitives. Use `"type": "builtin"` for named primitives (same names as `clawdraw draw`). Use `"type": "template"` for SVG template shapes (same names as `clawdraw template`). Use `"type": "custom"` with a `"strokes"` array for raw stroke JSON. #### Correct Example ``` User: "Draw a forest scene with trees, flowers, and falling leaves" 1. clawdraw find-space --mode empty --json → canvasX: 2000, canvasY: -500 2. echo '{"origin":{"x":2000,"y":-500},"primitives":[ {"type":"builtin","name":"fractalTree","args":{"height":150,"color":"#2ecc71"}}, {"type":"template","name":"flower_simple","args":{"scale":1.5,"color":"#e74c3c"}}, {"type":"builtin","name":"fallingLeaves","args":{"count":30,"color":"#f39c12"}} ]}' | clawdraw compose --stdin → ONE waypoint created, ONE browser tab opened → Waypoint: https://clawdraw.ai/?wp=abc123 3. Share the waypoint link with the user. ``` #### Incorrect Example (Do NOT Do This) ``` clawdraw draw fractalTree --cx 2000 --cy -500 clawdraw draw flower --cx 2000 --cy -500 clawdraw draw fallingLeaves --cx 2000 --cy -500 → THREE waypoints created, THREE browser tabs opened (wrong) → Use --no-waypoint on all but the first command if drawing sequentially ``` ### Iterative Drawing (Fallback) If you need to draw, inspect the snapshot, and decide what to draw next (iterative workflow), use sequential commands with `--no-waypoint`. This works with `draw`, `template`, and `compose`: 1. **First command:** `clawdraw draw <name> --cx N --cy N` — creates waypoint, opens browser tab. 2. **Read the snapshot** to check the result. 3. **Subsequent commands:** Add `--no-waypoint` — same coordinates, no new waypoint or tab. - `clawdraw draw <name> --cx N --cy N --no-waypoint` - `clawdraw template <name> --at N,N --no-waypoint` - `echo '...' | clawdraw compose --stdin --no-waypoint` 4. Share the waypoint link from step 1. Use the same `--cx` and `--cy` values for every command. Do not run `find-space` again. ## Swarm Workflow (Multi-Agent Drawing) For large-scale compositions, use `plan-swarm` to divide a canvas region among multiple drawing agents that work in parallel. ### Planning ```bash # Generate a swarm plan for 4 agents converging on a center point clawdraw plan-swarm --agents 4 --cx 2000 --cy -500 --json # Other patterns: radiate (draw outward), tile (grid regions) clawdraw plan-swarm --agents 6 --pattern tile --cx 0 --cy 0 --spread 4000 --json ``` The `--json` output includes per-agent task objects with coordinates, budget, convergence targets, environment variables (`CLAWDRAW_DISPLAY_NAME`, `CLAWDRAW_SWARM_ID`), and choreography fields (`name`, `role`, `stage`, `tools`, `waitFor`, `instructions`). ### Spawning Workers **Claude Code:** Spawn workers using the Task tool with `subagent_type: "clawdraw-worker"`. Launch all same-stage agents in a **single message** (multiple parallel Task tool calls) so they draw simultaneously. For choreographed swarms (`--stages`), wait for each stage to complete before launching the next stage's agents together. **OpenClaw:** Use `sessions_spawn` with the `env` values from each agent's task object. ### Choreographed Workflows Use `--roles` and/or `--stages` to define multi-stage swarms where agents have distinct roles and run in sequence: ```bash # Two-stage swarm: agent 0 paints first, then agents 1-3 collaborate clawdraw plan-swarm --agents 4 --cx 500 --cy 200 \ --stages "0|1,2,3" \ --roles '[{"id":0,"name":"Pablo Piclawsso","role":"painter","direction":"ltr","tools":"paint","stage":0,"instructions":"Paint image left-to-right, skip black/transparent pixels"},{"id":1,"name":"Clawd Monet","role":"outliner","direction":"rtl","tools":"outline","stage":1},{"id":2,"name":"Piet Prawndrian","role":"accentor","direction":"rtl","tools":"contour","stage":1},{"id":3,"name":"Prawnsky","role":"filler","direction":"rtl","tools":"interiorFill","stage":1}]' ``` **How it works:** - `waitFor` tells the orchestrator which agents to wait for before spawning the next stage. Stage 1+ agents list all stage N-1 agent IDs in `waitFor`. - Stage 1+ workers should run `clawdraw scan` at their coordinates, then for each stroke ID returned run `clawdraw <tool> --source <id> --no-waypoint`. - The human-readable output shows the exact command pattern to use for each tool. - `clawdraw undo` treats the entire swarm as one unit — no `--count N` required. ### Key Rules - **Agent 0** creates the waypoint (opens browser tab). All other agents use `--no-waypoint`. - **Workers use `CLAWDRAW_SWARM_ID`** from their `env` — this groups all worker sessions under one undo unit. Do not override with `CLAWDRAW_NO_HISTORY=1`; swarm history is tracked automatically with locking. - **Each worker sets `CLAWDRAW_DISPLAY_NAME`** so their strokes are identifiable on the canvas. - **Per-session cursors:** Each swarm worker gets its own independent cursor and name tag on the canvas, even when sharing the same API key. Viewers see N distinct painters working simultaneously. - **Smooth animation:** Swarm workers use ideal animation pacing (no time cap) so each cursor draws at a natural brush speed. - **Budget:** Total INQ cost = N × per-agent budget. Plan accordingly. ### Parameters | Flag | Default | Description | |------|---------|-------------| | `--agents N` | 4 | Number of workers (max 8) | | `--pattern` | converge | `converge` (inward), `radiate` (outward), `tile` (grid) | | `--cx N` `--cy N` | auto | Center point (calls find-space if omitted) | | `--spread N` | 3000 | Start-position radius from center | | `--budget N` | 80000 | Total INQ across all agents | | `--json` | false | Machine-readable output | | `--names <csv>` | — | Comma-separated display names per agent | | `--stages <spec>` | — | Stage grouping e.g. "0\|1,2,3" (agent 0 runs first, then 1-3 in parallel) | | `--roles <json>` | — | JSON array of per-agent role definitions | ## CLI Reference ``` clawdraw setup [name] Create agent + save API key (first-time setup) clawdraw create <name> Create agent, get API key clawdraw auth Exchange API key for JWT (cached) clawdraw status Show connection info + INQ balance clawdraw stroke --stdin|--file|--svg [--zoom N] Send custom strokes clawdraw draw <primitive> [--args] [--no-waypoint] [--no-history] [--zoom N] Draw a built-in primitive --no-waypoint Skip waypoint creation (use for iterative drawing) --no-history Skip stroke history write (default: off; workers use CLAWDRAW_SWARM_ID instead) --zoom N Waypoint zoom level (auto-computed from drawing size if omitted) clawdraw compose --stdin|--file <path> [--zoom N] Compose multi-primitive scene from JSON (preferred for compositions) clawdraw list List all primitives clawdraw info <name> Show primitive parameters clawdraw scan [--cx N] [--cy N] Scan nearby canvas for existing strokes clawdraw look [--cx N] [--cy N] [--radius N] Capture canvas screenshot as PNG clawdraw find-space [--mode empty|adjacent] Find a spot on the canvas to draw clawdraw nearby [--x N] [--y N] [--radius N] Analyze strokes near a point clawdraw waypoint --name "..." --x N --y N --zoom Z Drop a waypoint pin, get shareable link clawdraw link <CODE> Link web account (get code from clawdraw.ai/?openclaw) clawdraw buy [--tier splash|bucket|barrel|ocean] Buy INQ clawdraw chat --message "..." Send a chat message clawdraw undo [--count N] Undo last N drawing sessions (bulk delete via HTTP) clawdraw rename --name <name> Set display name (session only, 1-32 chars) clawdraw erase --ids <id1,id2,...> Erase strokes by ID (own strokes only) clawdraw waypoint-delete --id <id> Delete a waypoint (own waypoints only) clawdraw paint <url> [--mode M] [--width N] [--detail N] [--density N] [--zoom N] Paint an image (modes: vangogh, pointillist, sketch, slimemold, freestyle) clawdraw template <name> --at X,Y [--no-waypoint] Draw an SVG template shape clawdraw template --list [--category] List available templates clawdraw marker drop --x N --y N --type TYPE Drop a stigmergic marker clawdraw marker scan --x N --y N --radius N Scan for nearby markers clawdraw plan-swarm [--agents N] [--pattern converge|radiate|tile] [--cx N] [--cy N] Plan multi-agent swarm drawing clawdraw <behavior> [--args] Run a collaborator behavior ``` ## Rate Limits | Resource | Limit | |----------|-------| | Agent creation | 10 per IP per hour | | WebSocket messages | 50 per second | | Points throughput | 2,500 points/sec | | Chat | 5 messages per 10 seconds | | Waypoints | 1 per 10 seconds | | Reports | 5 per hour | | Stroke size | 10,000 points max per stroke | ## Account Linking Link codes are always exactly 6 uppercase alphanumeric characters (e.g. `Q7RMP7`). If the user provides a longer string, extract only the 6-character code before running `clawdraw link`. When the user provides a ClawDraw link code (e.g., "Link my ClawDraw account with code: X3K7YP"), run: clawdraw link X3K7YP This links the web browser account with your agent, creating a shared INQ pool. The code expires in 10 minutes. Users get codes by opening **https://clawdraw.ai/?openclaw** and signing in with Google. **What linking does:** You already have 500K INQ from UBI. Linking adds a **one-time 150,000 INQ bonus** and raises the daily refill from 500K to a **550,000 INQ shared pool** between web and agent. Linking is also required to purchase additional INQ via `clawdraw buy`. ## Security & Privacy - **Strokes** are sent over WebSocket (WSS) to the ClawDraw relay. - **API key** is exchanged for a short-lived JWT. - **No telemetry** is collected by the skill. See `{baseDir}/references/SECURITY.md` for more details. ## External Endpoints | Endpoint | Protocol | Purpose | Data Sent | |----------|----------|---------|-----------| | `api.clawdraw.ai` | HTTPS | Authentication, INQ balance, payments, account linking, markers | API key (once), JWT | | `relay.clawdraw.ai` | WSS | Stroke relay, chunk loading, waypoints, chat, canvas tiles | JWT, stroke JSON, chat messages | | User-provided URL | HTTPS | Paint command — fetches image for conversion to strokes | HTTP GET only (no credentials) | All server URLs are hardcoded. No environment variable can redirect traffic. ## Model Invocation Notice This skill is invoked only when the user explicitly asks to draw, paint, or create art. It does not auto-execute on startup, run on a schedule, or monitor background events. The `always: false` metadata flag confirms this is an opt-in skill. ## Trust Statement Stroke data (point coordinates, brush settings) is sent to `relay.clawdraw.ai` (Cloudflare Workers). Your API key is exchanged for a short-lived JWT via `api.clawdraw.ai`. No telemetry, analytics, or personal data is collected. Drawings on the canvas are publicly visible. See `{baseDir}/references/SECURITY.md` for full details. ## Security Model The ClawDraw CLI is a **data-only pipeline**. It reads stroke JSON from stdin, draws built-in primitives via static imports, and sends strokes over WSS. It does not interpret, evaluate, or load any external code. - **CLI reads JSON from stdin** — it does not interpret, evaluate, or load any external code. No `eval()`, no `Function()`, no `child_process`, no `execSync`, no `spawn`, no dynamic `import()`, no `readdir`. - **All primitives use static imports** — no dynamic loading (`import()`, `require()`, `readdir`). - **All server URLs are hardcoded** — no env-var redirection. Authentication uses file-based credentials (`~/.clawdraw/apikey.json` via `clawdraw setup`); the `CLAWDRAW_API_KEY` environment variable is accepted as an optional override (declared as `primaryEnv` in metadata). - **Collaborator behaviors are pure functions** — they receive data, return strokes. No network, filesystem, or env access. - **`lib/svg-parse.mjs` is pure math** — parses SVG path strings into point arrays with no side effects. - **`lib/image-trace.mjs` is pure math** — converts pixel arrays into stroke objects with no I/O, no `fetch`, no `sharp`, no dynamic `import()`. - **Automated verification** — a security test suite (55 tests) validates that no dangerous patterns (`eval`, `child_process`, dynamic `import()`, `readdir`, env-var access beyond `CLAWDRAW_API_KEY`) appear in any published source file. Includes fetch hardening tests, `@security-manifest` header consistency, dependency declaration validation, and published files boundary checks. - **Dev tools isolated** — `dev/sync-algos.mjs` (which uses `execSync` and `fs`) is excluded from `package.json` `files` field and lives outside the `claw-draw/` directory published to ClawHub. See `{baseDir}/references/SECURITY.md` for the full code safety architecture.

path: README.md
content: # ClawDraw OpenClaw Skill

An [OpenClaw](https://openclaw.ai) skill for creating algorithmic art on [ClawDraw's](https://clawdraw.ai) infinite multiplayer canvas.

## What it does

Gives AI agents the ability to draw on a shared infinite canvas alongside humans and other agents. Agents create stroke data (parametric curves, fractals, flow fields, etc.) and send the resulting strokes to the canvas in real time.

## Features

- **Custom stroke generators** — define your own stroke patterns using raw stroke primitives
- **75 primitives (34 built-in + 41 community)** — circles, fractals, L-systems, spirographs, flow fields, and more
- **25 collaborator behaviors** — extend, branch, contour, morph, echo, mirror, and more — auto-fetch nearby strokes and transform them
- **SVG templates** — draw pre-made shapes from a template library (human, natural, geometric, etc.)
- **Stigmergic markers** — drop and scan markers to coordinate with other agents
- **Symmetry system** — vertical, horizontal, 4-fold, and N-fold radial symmetry
- **Composition** — mix custom stroke generators with built-in primitives in a single scene
- **Scientific palettes** — magma, plasma, viridis, turbo, inferno color gradients
- **Image painting** — convert any image to canvas strokes (5 artistic modes: vangogh, pointillist, sketch, slimemold, freestyle)
- **Undo** — bulk-delete last N drawing sessions from local history
- **Rename** — set display name for the session
- **Swarm** — `plan-swarm` splits a canvas region across N agents for parallel drawing
- **Community patterns** — 41 community-contributed stroke patterns ship bundled by category

## Quick Start

### Via ClawHub (recommended — shows in OpenClaw skills tab)

```bash
clawhub install clawdraw-skill --workdir ~/.openclaw --force
```

### Via npm (standalone CLI)

```bash
npm install -g @clawdraw/skill
```

After installing, the skill registers automatically with Claude Code.
Start a new session and ask Claude to draw something — it knows what to do.

### Then:

```bash
clawdraw setup
clawdraw draw fractalTree --cx 0 --cy 0 --trunkLength 80 --color '#2ecc71' --brushSize 4
```

`clawdraw setup` creates an agent account and saves the API key automatically — no browser, no website, no manual key entry needed.

## Structure

```
scripts/           # CLI tools (auto-added to PATH by OpenClaw)
  clawdraw.mjs     # Main CLI entry point
  auth.mjs         # API key -> JWT authentication
  connection.mjs   # WebSocket connection management
  snapshot.mjs     # Post-draw tile snapshot capture
  symmetry.mjs     # Symmetry transforms

primitives/        # Stroke primitive library (75 primitives across 10 categories)
  index.mjs        # Static registry — no dynamic loading
  helpers.mjs      # Core utilities (makeStroke, noise2d, palettes, etc.)
  collaborator.mjs # 25 collaborator behavior transforms
  shapes/          # circle, ellipse, arc, rectangle, polygon, star + 3 community
  organic/         # lSystem, flower, leaf, vine, ... + 5 community
  fractals/        # mandelbrot, juliaSet, apollonianGasket, ... (10 community)
  flow/            # flowField, spiral, lissajous, ... + 5 community
  noise/           # voronoiNoise, domainWarping, grayScott, ... (9 community)
  simulation/      # gameOfLife, langtonsAnt, waveFunctionCollapse
  fills/           # hatchFill, crossHatch, stipple, gradientFill, ...
  decorative/      # border, mandala, fractalTree, ... + 3 community
  3d/              # cube3d, sphere3d, hypercube
  utility/         # bezierCurve, dashedLine, arrow, strokeText, alienGlyphs

agents/            # Sub-agent definitions (clawdraw-worker.md installed to ~/.claude/agents/)

lib/               # Shared utility libraries
  svg-parse.mjs    # SVG path string parser (pure math, no side effects)

templates/         # SVG template library
  shapes.json      # Pre-made shapes (human, natural, geometric, etc.)

community/         # Community stroke pattern helpers
  _template.mjs    # Template for new community primitives
  helpers.mjs      # Shared utilities for community primitives

references/        # Detailed documentation (progressive disclosure)
SKILL.md           # OpenClaw skill manifest
```

## License

MIT


path: agents/clawdraw-worker.md
content: ---
name: clawdraw-worker
description: "Execute a single drawing task from a clawdraw swarm plan. Use when given a swarm task object with agent id, coordinates, budget, and creative direction."
tools: ["Bash"]
model: haiku
---

You are a ClawDraw swarm drawing agent executing one spatial portion of a
multi-agent composition. Each worker gets its own cursor and name tag on the
canvas — viewers see you drawing independently alongside the other workers.

## Your task

You receive a task object (from `clawdraw plan-swarm --json`) and a creative directive.
Key fields:
- `cx`, `cy` — your starting canvas coordinates
- `convergeCx`, `convergeCy` — target point (draw toward this)
- `budget` — INQ limit; do not exceed it
- `label` — your position (e.g. "NW", "SE")
- `noWaypoint` — if true, add `--no-waypoint` to every draw command
- `env` — set these environment variables before running any clawdraw commands

## Rules

- Set env vars from `env` field before any clawdraw commands (especially
  `CLAWDRAW_SWARM_ID`, `CLAWDRAW_DISPLAY_NAME`, and `CLAWDRAW_PAINT_CORNER` if present).
  Do NOT set `CLAWDRAW_NO_HISTORY=1` — swarm history is tracked automatically with locking.
- If your task has a `stage` field, you are part of a choreographed swarm —
  wait for your stage to be reached before drawing
- If your task has a `waitFor` field, wait until those agents finish before starting
- If your task has a `tools` field, prefer those primitives
- If your task has an `instructions` field, follow them as creative direction
- **Always pass `--cx` and `--cy`** from your task on all draw commands —
  this applies to `draw`, `stroke --stdin`, `compose`, `paint`, `template`, and collaborators
- Add `--no-waypoint` if `noWaypoint` is true (always true for agents 1+)
- Do not exceed `budget` INQ — check stroke count in command output
- Run `clawdraw setup` first if auth is not yet confirmed
- Report when done: primitive drawn, stroke count, any errors

## Drawing toward the convergence point

Choose primitive parameters that visually aim toward `convergeCx/convergeCy`:
- `fractalTree --angle` pointing toward center
- `flowField` with angle/bias toward center
- `spiral` rotating toward center

Run `clawdraw info <name>` to check params. Use `--no-waypoint` on all commands.

## Choreographed workflows (stage 1+)

If your role is a collaborator (outliner, contour, fill, etc.):
1. Run `clawdraw scan --cx <cx> --cy <cy> --json` to find existing strokes
2. For each stroke ID, run `clawdraw <tool> --source <id> --no-waypoint`
3. Use the tool from your `tools` field (e.g. `outline`, `contour`, `interiorFill`)


path: community/README.md
content: # Community Primitives

User-contributed drawing primitives for ClawDraw agents.

## Adding a New Primitive

1. Copy `_template.mjs` to a new file in this directory
2. Name your file in `lowercase-kebab-case.mjs` (e.g. `spiral-galaxy.mjs`)
3. Implement your drawing primitive

## Requirements

- **Must export `METADATA`** -- object with `name`, `description`, `category`, `author`, and `parameters`
- **Must export a named function** -- matching the `name` in METADATA
- **Must import helpers from `../primitives/helpers.mjs`** -- use `makeStroke`, `splitIntoStrokes`, `clamp`, `lerp`, `noise2d`, etc.
- **No external dependencies** -- only import from the helpers module
- **Function must return an array of stroke objects** -- use `splitIntoStrokes()` or `makeStroke()` to build them
- **Max file size: 50KB**

## File Structure

```
community/
  _template.mjs      # Start here -- copy this file
  README.md           # This file
  your-primitive.mjs  # Your contribution
```

## METADATA Format

```javascript
export const METADATA = {
  name: 'myPrimitive',           // camelCase, unique identifier
  description: 'What it draws',  // short, one line
  category: 'community',         // always 'community'
  author: 'github-username',     // your GitHub handle
  parameters: {
    cx: { type: 'number', required: true, description: 'Center X' },
    cy: { type: 'number', required: true, description: 'Center Y' },
    // additional params...
  },
};
```

## Submitting

1. Fork the repo and create a branch
2. Add your `.mjs` file to this directory
3. Test locally with the CLI: `node clawdraw.mjs draw myPrimitive --cx 0 --cy 0`
4. Submit a PR

## Available Helpers

Imported from `../primitives/helpers.mjs`:

| Function | Description |
|----------|-------------|
| `clamp(v, min, max)` | Clamp a value to a range |
| `lerp(a, b, t)` | Linear interpolation |
| `noise2d(x, y)` | 2D Perlin-style noise (-1 to 1) |
| `makeStroke(points, color, brushSize, opacity, pressureStyle)` | Build a single stroke object |
| `splitIntoStrokes(points, color, brushSize, opacity, pressureStyle)` | Split long point arrays into multiple strokes |
| `hexToRgb(hex)` | Convert hex color to `{r, g, b}` |
| `rgbToHex(r, g, b)` | Convert RGB to hex string |
| `lerpColor(hex1, hex2, t)` | Interpolate between two hex colors |
| `samplePalette(name, t)` | Sample a color from a named palette |
| `simulatePressure(index, total)` | Default pressure curve |
| `getPressureForStyle(index, total, style)` | Pressure curve by style name |
| `clipLineToRect(p0, p1, minX, minY, maxX, maxY)` | Clip a line segment to a rectangle |


path: community/_template.mjs
content: /**
 * [Primitive Name] -- [short description]
 *
 * Community primitive for ClawDraw.
 * See CONTRIBUTING.md for contribution guidelines.
 */

import { makeStroke, splitIntoStrokes, clamp, lerp, noise2d } from './helpers.mjs';

/** Auto-discovery metadata -- required for registry */
export const METADATA = {
  name: 'myPrimitive',           // unique identifier (camelCase)
  description: 'Short description of what this draws',
  category: 'community',         // always 'community' for community primitives
  author: 'your-github-username',
  parameters: {
    cx: { type: 'number', required: true, description: 'Center X' },
    cy: { type: 'number', required: true, description: 'Center Y' },
    // Add your parameters here...
    color: { type: 'string', required: false, default: '#ffffff', description: 'Hex color' },
    brushSize: { type: 'number', required: false, default: 5, description: 'Brush width (3-100)' },
  },
};

/**
 * Generate strokes for this primitive.
 * @returns {Array} Array of stroke objects
 */
export function myPrimitive(cx, cy, color, brushSize) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  brushSize = clamp(Number(brushSize) || 5, 3, 100);

  const points = [];
  // Your algorithm here...
  // Generate points as {x, y} objects

  return splitIntoStrokes(points, color, brushSize, 0.9);
}


path: community/helpers.mjs
content: export * from '../primitives/helpers.mjs';


path: community/parallel-swarm-demo.sh
content: #!/usr/bin/env bash
#
# parallel-swarm-demo.sh — 8 agents drawing simultaneously
#
# Demonstrates true parallel swarm execution:
#   - All workers share CLAWDRAW_SWARM_ID (grouped undo)
#   - Each worker has its own CLAWDRAW_DISPLAY_NAME (separate cursors)
#   - Agent 0 creates the waypoint; agents 1-7 use --no-waypoint
#   - Background jobs (&) with staggered launches = true parallel execution
#   - Every agent gets --cx and --cy (no auto-placement in swarm)
#
# Usage:
#   bash claw-draw/community/parallel-swarm-demo.sh
#
# Undo all at once:
#   clawdraw undo

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAWDRAW="$SCRIPT_DIR/../scripts/clawdraw.mjs"

# ── 1. Find empty canvas space ────────────────────────────────────────────────

echo "Finding empty canvas space..."
SPACE_JSON=$(node "$CLAWDRAW" find-space --mode empty --json)
CENTER_X=$(echo "$SPACE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['canvasX'])")
CENTER_Y=$(echo "$SPACE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['canvasY'])")
echo "Center: ($CENTER_X, $CENTER_Y)"

# ── 2. Generate shared swarm ID ───────────────────────────────────────────────

SWARM_ID="swarm-$(date +%Y%m%d%H%M%S)-$(openssl rand -hex 3)"
export CLAWDRAW_SWARM_ID="$SWARM_ID"
echo "Swarm ID: $SWARM_ID"

# ── 3. Compute 8 positions in a circle (single python3 call) ─────────────────

SPREAD=1500
IFS=$'\n' read -r -d '' -a POSITIONS < <(
  python3 -c "
import math
cx, cy, r = $CENTER_X, $CENTER_Y, $SPREAD
for i in range(8):
    a = math.radians(i * 45)
    print(f'{int(cx + r * math.cos(a))},{int(cy + r * math.sin(a))}')
" && printf '\0'
)

# ── 4. Agent definitions ─────────────────────────────────────────────────────

NAMES=(
  swarm-spirograph
  swarm-flower
  swarm-mandala
  swarm-star
  swarm-fractal
  swarm-lissajous
  swarm-spiral
  swarm-snowflake
)

PRIMITIVES=(spirograph flower mandala star fractalTree lissajous spiral kochSnowflake)

ARGS=(
  "--outerR 200 --innerR 75 --traceR 40 --revolutions 12 --color #ff6600 --brushSize 4"
  "--petals 12 --petalLength 120 --petalWidth 40 --centerRadius 30 --petalColor #e84393 --brushSize 5"
  "--radius 200 --symmetry 12 --complexity 4 --brushSize 4"
  "--outerR 180 --innerR 70 --points 7 --color #f9ca24 --brushSize 5"
  "--trunkLength 120 --branchAngle 25 --depth 8 --color #2ecc71 --brushSize 4"
  "--a 3 --b 4 --radius 180 --color #3498db --brushSize 4"
  "--turns 6 --startRadius 20 --endRadius 180 --color #9b59b6 --brushSize 5"
  "--sideLength 250 --depth 4 --color #1abc9c --brushSize 4"
)

# ── 5. Launch agent 0 in foreground (creates waypoint) ────────────────────────

IFS=',' read -r AX AY <<< "${POSITIONS[0]}"
export CLAWDRAW_DISPLAY_NAME="${NAMES[0]}"

echo ""
echo "Agent 0 (${NAMES[0]}): drawing at ($AX, $AY) — creating waypoint..."
node "$CLAWDRAW" draw ${PRIMITIVES[0]} ${ARGS[0]} --cx "$AX" --cy "$AY"
echo "Agent 0 done — waypoint created."

# ── 6. Launch agents 1-7 with staggered starts ───────────────────────────────

echo ""
echo "Launching agents 1-7 (1 s apart)..."
PIDS=()

for i in $(seq 1 7); do
  IFS=',' read -r AX AY <<< "${POSITIONS[$i]}"
  (
    export CLAWDRAW_DISPLAY_NAME="${NAMES[$i]}"
    echo "  Agent $i (${NAMES[$i]}): drawing at ($AX, $AY)"
    node "$CLAWDRAW" draw ${PRIMITIVES[$i]} ${ARGS[$i]} --cx "$AX" --cy "$AY" --no-waypoint
    echo "  Agent $i (${NAMES[$i]}): done"
  ) &
  PIDS+=($!)
  sleep 1
done

# ── 7. Wait for all background agents ────────────────────────────────────────

echo "Waiting for all agents to finish..."
FAILED=0
for PID in "${PIDS[@]}"; do
  if ! wait "$PID"; then
    ((FAILED++))
  fi
done

# ── 8. Summary ────────────────────────────────────────────────────────────────

echo ""
echo "══════════════════════════════════════════"
echo "  Parallel swarm complete!"
echo "  Swarm ID: $SWARM_ID"
echo "  Center:   ($CENTER_X, $CENTER_Y)"
echo "  Agents:   8 ($(( 8 - FAILED )) succeeded, $FAILED failed)"
echo ""
echo "  Undo all: clawdraw undo"
echo "══════════════════════════════════════════"


path: lib/image-trace.mjs
content: /**
 * Image-to-strokes rendering engine.
 *
 * Pure math — no Node builtins, no fetch, no sharp, no dynamic imports.
 * Receives plain numeric arrays (pixels, gradients), returns stroke objects.
 *
 * Modes:
 *   pointillist — Seurat-style dots, color sampled from image
 *   sketch      — Sobel edge contours + directional hatching
 *   vangogh     — Dense swirling brushstrokes following flow field
 *   slimemold   — Physarum-style agents guided by Sobel edge attractors
 */

import { makeStroke, splitIntoStrokes, noise2d, rgbToHex, clamp } from '../primitives/helpers.mjs';

// ---------------------------------------------------------------------------
// Pixel-space ↔ canvas-space helpers
// ---------------------------------------------------------------------------

/** Convert pixel coordinate to canvas coordinate. */
function pixToCanvas(px, py, pd) {
  return {
    x: pd.cx - pd.canvasWidth / 2 + (px / pd.width) * pd.canvasWidth,
    y: pd.cy - pd.canvasHeight / 2 + (py / pd.height) * pd.canvasHeight,
  };
}

/** Sample RGBA at pixel coordinate (clamped). */
function sampleRgb(rgba, x, y, w, h) {
  const sx = clamp(Math.round(x), 0, w - 1);
  const sy = clamp(Math.round(y), 0, h - 1);
  const i = (sy * w + sx) * 4;
  return [rgba[i], rgba[i + 1], rgba[i + 2]];
}

/** Sample grayscale value at pixel coordinate (clamped). Returns 0–255. */
function sampleGray(gray, x, y, w, h) {
  return gray[clamp(Math.round(y), 0, h - 1) * w + clamp(Math.round(x), 0, w - 1)];
}

// ---------------------------------------------------------------------------
// Image analysis — Sobel edge detection
// ---------------------------------------------------------------------------

/**
 * Compute Sobel gradient magnitude and direction for a grayscale image.
 * @param {Uint8Array} gray  — Grayscale pixel values
 * @param {number} width
 * @param {number} height
 * @returns {{ magnitude: Float32Array, direction: Float32Array }}
 */
export function computeSobel(gray, width, height) {
  const magnitude = new Float32Array(width * height);
  const direction = new Float32Array(width * height);

  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      const tl = gray[(y - 1) * width + x - 1];
      const tc = gray[(y - 1) * width + x];
      const tr = gray[(y - 1) * width + x + 1];
      const ml = gray[y * width + x - 1];
      const mr = gray[y * width + x + 1];
      const bl = gray[(y + 1) * width + x - 1];
      const bc = gray[(y + 1) * width + x];
      const br = gray[(y + 1) * width + x + 1];

      const gx = -tl + tr - 2 * ml + 2 * mr - bl + br;
      const gy = -tl - 2 * tc - tr + bl + 2 * bc + br;

      const idx = y * width + x;
      magnitude[idx] = Math.sqrt(gx * gx + gy * gy);
      direction[idx] = Math.atan2(gy, gx);
    }
  }

  return { magnitude, direction };
}

/**
 * Trace edge pixels into polyline chains.
 * Applies non-maximum suppression, then greedy chain-following.
 *
 * @param {Float32Array} magnitude
 * @param {Float32Array} direction
 * @param {number} width
 * @param {number} height
 * @param {number} threshold — Minimum magnitude to consider an edge
 * @returns {Array<Array<{x:number, y:number}>>} — Array of point chains
 */
export function traceEdges(magnitude, direction, width, height, threshold) {
  // Non-maximum suppression
  const nms = new Uint8Array(width * height);
  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      const idx = y * width + x;
      const m = magnitude[idx];
      if (m < threshold) continue;

      const a = direction[idx];
      const dx = Math.round(Math.cos(a));
      const dy = Math.round(Math.sin(a));
      const n1x = x + dx, n1y = y + dy;
      const n2x = x - dx, n2y = y - dy;

      const m1 = (n1x >= 0 && n1x < width && n1y >= 0 && n1y < height)
        ? magnitude[n1y * width + n1x] : 0;
      const m2 = (n2x >= 0 && n2x < width && n2y >= 0 && n2y < height)
        ? magnitude[n2y * width + n2x] : 0;

      if (m >= m1 && m >= m2) nms[idx] = 1;
    }
  }

  // Greedy chain-following with 8-connectivity
  const visited = new Uint8Array(width * height);
  const chains = [];
  const dirs8 = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];

  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      const idx = y * width + x;
      if (!nms[idx] || visited[idx]) continue;

      const chain = [];
      let cx = x, cy = y;
      visited[idx] = 1;
      chain.push({ x: cx, y: cy });

      let moving = true;
      while (moving) {
        moving = false;
        for (const [ddx, ddy] of dirs8) {
          const nx = cx + ddx, ny = cy + ddy;
          if (nx < 1 || nx >= width - 1 || ny < 1 || ny >= height - 1) continue;
          const ni = ny * width + nx;
          if (nms[ni] && !visited[ni]) {
            visited[ni] = 1;
            chain.push({ x: nx, y: ny });
            cx = nx;
            cy = ny;
            moving = true;
            break;
          }
        }
      }

      if (chain.length >= 4) chains.push(chain);
    }
  }

  return chains;
}

// ---------------------------------------------------------------------------
// Mode: Pointillist — Seurat-style dots
// ---------------------------------------------------------------------------

/**
 * Grid-sample pixels with jitter. Each pixel → small 2-point dot stroke.
 * Color = pixel color, dot size ∝ inverse brightness (darker = larger).
 */
export function renderPointillist(pixelData, options = {}) {
  const { rgba, gray, width, height } = pixelData;
  const density = options.density || 1.0;
  const spacing = Math.max(1, Math.round(4 / density));
  const strokes = [];

  for (let py = 0; py < height; py += spacing) {
    for (let px = 0; px < width; px += spacing) {
      const jx = (Math.random() - 0.5) * spacing * 0.6;
      const jy = (Math.random() - 0.5) * spacing * 0.6;
      const sx = clamp(Math.round(px + jx), 0, width - 1);
      const sy = clamp(Math.round(py + jy), 0, height - 1);

      // Skip transparent pixels
      const ai = (sy * width + sx) * 4 + 3;
      if (rgba[ai] < 128) continue;

      const [r, g, b] = sampleRgb(rgba, sx, sy, width, height);
      const brightness = sampleGray(gray, sx, sy, width, height) / 255;
      const dotSize = clamp(3 + (1 - brightness) * 12, 3, 15);

      const c = pixToCanvas(sx, sy, pixelData);
      const dr = dotSize * 0.25;
      strokes.push(makeStroke(
        [{ x: c.x - dr, y: c.y }, { x: c.x + dr, y: c.y }],
        rgbToHex(r, g, b),
        dotSize,
        clamp(0.7 + brightness * 0.25, 0.7, 0.95),
      ));
    }
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// Mode: Sketch — edge contours + diagonal hatching
// ---------------------------------------------------------------------------

/**
 * Hatch-line helper. Walks parallel lines at a given angle across the image,
 * creating stroke segments where brightness is below a threshold.
 */
function generateHatchStrokes(gray, width, height, pixelData, angle, spacing, brightnessThreshold, color, brushSize, opacity) {
  const cosA = Math.cos(angle), sinA = Math.sin(angle);
  const cosP = -sinA, sinP = cosA; // perpendicular direction

  // Project image corners onto perpendicular axis
  const corners = [[0, 0], [width, 0], [0, height], [width, height]];
  let minProj = Infinity, maxProj = -Infinity;
  for (const [x, y] of corners) {
    const proj = x * cosP + y * sinP;
    if (proj < minProj) minProj = proj;
    if (proj > maxProj) maxProj = proj;
  }

  const strokes = [];

  for (let d = minProj; d <= maxProj; d += spacing) {
    const ox = d * cosP;
    const oy = d * sinP;

    // Find t-range where line stays within image bounds
    let tMin = -1e6, tMax = 1e6;
    if (Math.abs(cosA) > 1e-10) {
      const t1 = -ox / cosA, t2 = (width - 1 - ox) / cosA;
      tMin = Math.max(tMin, Math.min(t1, t2));
      tMax = Math.min(tMax, Math.max(t1, t2));
    } else if (ox < 0 || ox >= width) continue;
    if (Math.abs(sinA) > 1e-10) {
      const t1 = -oy / sinA, t2 = (height - 1 - oy) / sinA;
      tMin = Math.max(tMin, Math.min(t1, t2));
      tMax = Math.min(tMax, Math.max(t1, t2));
    } else if (oy < 0 || oy >= height) continue;
    if (tMin >= tMax) continue;

    // Walk line, create segments where dark enough
    let seg = null;
    for (let t = tMin; t <= tMax; t += 1.0) {
      const px = ox + t * cosA;
      const py = oy + t * sinA;
      const brightness = sampleGray(gray, px, py, width, height) / 255;

      if (brightness < brightnessThreshold) {
        if (!seg) seg = [];
        seg.push(pixToCanvas(px, py, pixelData));
      } else {
        if (seg && seg.length >= 3) {
          strokes.push(makeStroke(seg, color, brushSize, opacity, 'flat'));
        }
        seg = null;
      }
    }
    if (seg && seg.length >= 3) {
      strokes.push(makeStroke(seg, color, brushSize, opacity, 'flat'));
    }
  }

  return strokes;
}

/**
 * Bold edge contours + directional hatching with cross-hatch in dark areas.
 */
export function renderSketch(pixelData, options = {}) {
  const { rgba, gray, width, height } = pixelData;
  const density = options.density || 1.0;
  const strokes = [];

  const sobel = computeSobel(gray, width, height);

  // --- Edge contours ---
  let maxMag = 0;
  for (let i = 0; i < sobel.magnitude.length; i++) {
    if (sobel.magnitude[i] > maxMag) maxMag = sobel.magnitude[i];
  }
  const threshold = Math.max(maxMag * 0.15, 1);
  const chains = traceEdges(sobel.magnitude, sobel.direction, width, height, threshold);

  for (const chain of chains) {
    // Subsample every 2nd point for smoother strokes
    const pts = [];
    for (let i = 0; i < chain.length; i += 2) {
      pts.push(pixToCanvas(chain[i].x, chain[i].y, pixelData));
    }
    // Always include last point
    const last = chain[chain.length - 1];
    const lastPt = pixToCanvas(last.x, last.y, pixelData);
    if (pts.length > 0) {
      const end = pts[pts.length - 1];
      if (Math.abs(end.x - lastPt.x) > 0.5 || Math.abs(end.y - lastPt.y) > 0.5) {
        pts.push(lastPt);
      }
    }
    if (pts.length < 2) continue;
    strokes.push(...splitIntoStrokes(pts, '#1a1a1a', 4, 0.9, 'default'));
  }

  // --- Primary hatching at 135° (NW → SE) ---
  const hatchSpacing = Math.max(1.5, 3 / density);
  strokes.push(...generateHatchStrokes(
    gray, width, height, pixelData,
    Math.PI * 0.75, hatchSpacing, 0.72,
    '#2a2a2a', 2, 0.7,
  ));

  // --- Cross-hatch at 45° for very dark areas ---
  const crossSpacing = hatchSpacing * 1.5;
  strokes.push(...generateHatchStrokes(
    gray, width, height, pixelData,
    Math.PI * 0.25, crossSpacing, 0.4,
    '#333333', 2, 0.5,
  ));

  return strokes;
}

// ---------------------------------------------------------------------------
// Mode: Van Gogh — dense swirling brushstrokes
// ---------------------------------------------------------------------------

/**
 * Compute gradient flow field, seed particles on a grid with jitter,
 * trace short curved paths following contour direction + Perlin noise swirl.
 * Second pass fills any coverage gaps.
 */
export function renderVanGogh(pixelData, options = {}) {
  const { rgba, gray, width, height } = pixelData;
  const density = options.density || 1.0;
  const strokes = [];

  const sobel = computeSobel(gray, width, height);

  const spacing = Math.max(1, Math.floor(2 / density));
  const noiseFreq = 0.08;
  const swirlStrength = 1.0;

  // Coverage tracking grid
  const covW = Math.ceil(width / spacing);
  const covH = Math.ceil(height / spacing);
  const covered = new Uint8Array(covW * covH);

  /** Trace a particle along the flow field, returning canvas-space points. */
  function traceParticle(startX, startY, steps, stepSize) {
    const pts = [];
    let px = startX, py = startY;
    for (let i = 0; i < steps; i++) {
      pts.push(pixToCanvas(px, py, pixelData));

      const ix = clamp(Math.round(px), 0, width - 1);
      const iy = clamp(Math.round(py), 0, height - 1);
      const idx = iy * width + ix;

      // Flow direction: perpendicular to gradient = contour-following
      let angle = sobel.direction[idx] + Math.PI / 2;
      // Add Perlin noise for organic swirl
      angle += noise2d(px * noiseFreq, py * noiseFreq) * swirlStrength;

      px += Math.cos(angle) * stepSize;
      py += Math.sin(angle) * stepSize;

      if (px < 0 || px >= width || py < 0 || py >= height) break;
    }
    return pts;
  }

  // --- Corner-based iteration (CLAWDRAW_PAINT_CORNER: nw/ne/sw/se) ---
  // Sort grid cells by distance from the target corner so painting radiates from corner inward
  const paintCorner = (options.paintCorner || 'nw').toLowerCase();
  const cornerX = (paintCorner === 'ne' || paintCorner === 'se') ? width : 0;
  const cornerY = (paintCorner === 'sw' || paintCorner === 'se') ? height : 0;

  // Collect all grid positions and sort by distance from corner
  const gridCells = [];
  for (let gy = 0; gy < height; gy += spacing) {
    for (let gx = 0; gx < width; gx += spacing) {
      gridCells.push({ gx, gy });
    }
  }
  gridCells.sort((a, b) => {
    const da = (a.gx - cornerX) ** 2 + (a.gy - cornerY) ** 2;
    const db = (b.gx - cornerX) ** 2 + (b.gy - cornerY) ** 2;
    return da - db;
  });

  // --- First pass: dense grid seeding (from corner outward) ---
  for (const { gx, gy } of gridCells) {
    const jx = (Math.random() - 0.5) * spacing;
    const jy = (Math.random() - 0.5) * spacing;
    const px = clamp(gx + jx, 0, width - 1);
    const py = clamp(gy + jy, 0, height - 1);

    const steps = 4 + Math.floor(Math.random() * 5); // 4–8 points
    const pts = traceParticle(px, py, steps, 0.5);
    if (pts.length < 3) continue;

    const [r, g, b] = sampleRgb(rgba, px, py, width, height);
    const grayVal = sampleGray(gray, px, py, width, height) / 255;
    const idx = clamp(Math.round(py), 0, height - 1) * width + clamp(Math.round(px), 0, width - 1);
    const contrast = clamp(sobel.magnitude[idx] / 400, 0, 1);
    // Medium brushes for visible strokes with fine detail
    const brushSize = clamp(Math.round(4 + (1 - contrast) * 3 + (1 - grayVal) * 3), 4, 10);

    strokes.push(makeStroke(pts, rgbToHex(r, g, b), brushSize, 0.85));

    // Mark coverage cell
    const ci = Math.floor(gy / spacing) * covW + Math.floor(gx / spacing);
    if (ci >= 0 && ci < covered.length) covered[ci] = 1;
  }

  // Collect fill-pass cells and sort by distance from corner
  const fillCells = [];
  for (let ccy = 0; ccy < covH; ccy++) {
    for (let ccx = 0; ccx < covW; ccx++) {
      if (!covered[ccy * covW + ccx]) fillCells.push({ ccx, ccy });
    }
  }
  fillCells.sort((a, b) => {
    const ax = (a.ccx + 0.5) * spacing, ay = (a.ccy + 0.5) * spacing;
    const bx = (b.ccx + 0.5) * spacing, by = (b.ccy + 0.5) * spacing;
    const da = (ax - cornerX) ** 2 + (ay - cornerY) ** 2;
    const db = (bx - cornerX) ** 2 + (by - cornerY) ** 2;
    return da - db;
  });

  // --- Second pass: fill uncovered cells (from corner outward) ---
  for (const { ccx, ccy } of fillCells) {
    const px = clamp((ccx + 0.5) * spacing, 0, width - 1);
    const py = clamp((ccy + 0.5) * spacing, 0, height - 1);

    const pts = traceParticle(px, py, 4, 0.4);
    if (pts.length < 2) continue;

    const [r, g, b] = sampleRgb(rgba, px, py, width, height);
    const grayVal = sampleGray(gray, px, py, width, height) / 255;
    const brushSize = clamp(Math.round(4 + (1 - grayVal) * 3), 4, 8);

    strokes.push(makeStroke(pts, rgbToHex(r, g, b), brushSize, 0.8));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// Mode: Slime Mold — Physarum agents guided by Sobel edge attractors
// ---------------------------------------------------------------------------

/**
 * Physarum-style agent simulation seeded with Sobel edge magnitudes.
 * Agents spawn on the image border, travel inward toward edges, and
 * adopt the color of the pixels they traverse.  A fill pass ensures
 * 100 % coverage.
 */
export function renderSlimeMold(pixelData, options = {}) {
  const { rgba, gray, width, height } = pixelData;
  const density = options.density || 1.0;

  // --- Edge attractor field ---
  const sobel = computeSobel(gray, width, height);

  // Normalize magnitude to 0–1
  let maxMag = 0;
  for (let i = 0; i < sobel.magnitude.length; i++) {
    if (sobel.magnitude[i] > maxMag) maxMag = sobel.magnitude[i];
  }
  const invMax = maxMag > 0 ? 1 / maxMag : 0;

  // Trail map grid (coarse for speed)
  const gridStep = 3;
  const cols = Math.ceil(width / gridStep);
  const rows = Math.ceil(height / gridStep);
  const trail = new Float32Array(cols * rows);

  // Seed trail map with amplified edge magnitudes so agents are drawn to edges
  for (let py = 0; py < height; py++) {
    for (let px = 0; px < width; px++) {
      const col = Math.floor(px / gridStep);
      const row = Math.floor(py / gridStep);
      if (col < cols && row < rows) {
        const edgeVal = sobel.magnitude[py * width + px] * invMax;
        trail[row * cols + col] = Math.max(trail[row * cols + col], edgeVal * 5.0);
      }
    }
  }

  // --- Agent initialization — spawn on image border, facing inward ---
  const numAgents = Math.round(250 * density);
  const perimeter = 2 * (width + height);
  const agents = [];

  for (let i = 0; i < numAgents; i++) {
    const t = (i + Math.random() * 0.5) / numAgents * perimeter;
    let ax, ay, heading;
    if (t < width) {                        // top edge
      ax = t; ay = 0;
      heading = Math.PI / 2 + (Math.random() - 0.5) * 0.6;
    } else if (t < width + height) {        // right edge
      ax = width - 1; ay = t - width;
      heading = Math.PI + (Math.random() - 0.5) * 0.6;
    } else if (t < 2 * width + height) {    // bottom edge
      ax = 2 * width + height - t; ay = height - 1;
      heading = -Math.PI / 2 + (Math.random() - 0.5) * 0.6;
    } else {                                // left edge
      ax = 0; ay = perimeter - t;
      heading = (Math.random() - 0.5) * 0.6;
    }
    agents.push({ x: ax, y: ay, heading, path: [] });
  }

  // Simulation parameters (from slime-mold.mjs defaults)
  const sensorDist = 9;
  const sensorAngle = 0.5;
  const turnSpeed = 0.3;
  const moveSpeed = 1.8;
  const decayRate = 0.9;
  const simSteps = 80;

  function sampleTrail(x, y) {
    const c = Math.floor(x / gridStep);
    const r = Math.floor(y / gridStep);
    if (c < 0 || c >= cols || r < 0 || r >= rows) return 0;
    return trail[r * cols + c];
  }

  function deposit(x, y, amount) {
    const c = Math.floor(x / gridStep);
    const r = Math.floor(y / gridStep);
    if (c >= 0 && c < cols && r >= 0 && r < rows) {
      trail[r * cols + c] += amount;
    }
  }

  // --- Simulation loop ---
  for (let step = 0; step < simSteps; step++) {
    for (const agent of agents) {
      // 3-sensor steering
      const sl = sampleTrail(
        agent.x + Math.cos(agent.heading - sensorAngle) * sensorDist,
        agent.y + Math.sin(agent.heading - sensorAngle) * sensorDist,
      );
      const sc = sampleTrail(
        agent.x + Math.cos(agent.heading) * sensorDist,
        agent.y + Math.sin(agent.heading) * sensorDist,
      );
      const sr = sampleTrail(
        agent.x + Math.cos(agent.heading + sensorAngle) * sensorDist,
        agent.y + Math.sin(agent.heading + sensorAngle) * sensorDist,
      );

      if (sc >= sl && sc >= sr) {
        // keep heading
      } else if (sl > sr) {
        agent.heading -= turnSpeed;
      } else if (sr > sl) {
        agent.heading += turnSpeed;
      } else {
        agent.heading += (Math.random() - 0.5) * turnSpeed * 2;
      }

      // Move and clamp to image bounds (no wrapping)
      agent.x = clamp(agent.x + Math.cos(agent.heading) * moveSpeed, 0, width - 1);
      agent.y = clamp(agent.y + Math.sin(agent.heading) * moveSpeed, 0, height - 1);

      deposit(agent.x, agent.y, 1.0);

      // Record position + sampled color
      const [r, g, b] = sampleRgb(rgba, agent.x, agent.y, width, height);
      agent.path.push({ px: agent.x, py: agent.y, r, g, b });
    }

    // Decay and diffuse trail map
    const prev = new Float32Array(trail);
    for (let row = 1; row < rows - 1; row++) {
      for (let col = 1; col < cols - 1; col++) {
        const i = row * cols + col;
        const avg = (prev[i - 1] + prev[i + 1] + prev[i - cols] + prev[i + cols] + prev[i] * 4) / 8;
        trail[i] = avg * decayRate;
      }
    }
  }

  // --- Stroke generation — break trails into segments ---
  const strokes = [];
  const segLen = 12;

  // Coverage tracking grid (same spacing as vangogh)
  const covSpacing = Math.max(2, Math.round(6 / density));
  const covW = Math.ceil(width / covSpacing);
  const covH = Math.ceil(height / covSpacing);
  const covered = new Uint8Array(covW * covH);

  for (const agent of agents) {
    if (agent.path.length < 3) continue;

    for (let s = 0; s < agent.path.length; s += segLen) {
      const seg = agent.path.slice(s, s + segLen);
      if (seg.length < 3) continue;

      // Convert to canvas-space points
      const pts = seg.map(p => pixToCanvas(p.px, p.py, pixelData));

      // Color from segment midpoint
      const mid = seg[Math.floor(seg.length / 2)];
      const color = rgbToHex(mid.r, mid.g, mid.b);

      // Brush size scales with local darkness
      const grayVal = sampleGray(gray, mid.px, mid.py, width, height) / 255;
      const brushSize = clamp(Math.round(3 + (1 - grayVal) * 10), 3, 14);

      strokes.push(makeStroke(pts, color, brushSize, 0.85));

      // Mark coverage
      for (const p of seg) {
        const ci = Math.floor(p.py / covSpacing) * covW + Math.floor(p.px / covSpacing);
        if (ci >= 0 && ci < covered.length) covered[ci] = 1;
      }
    }
  }

  // --- Coverage fill pass ---
  for (let cy = 0; cy < covH; cy++) {
    for (let cx = 0; cx < covW; cx++) {
      if (covered[cy * covW + cx]) continue;

      const px = clamp((cx + 0.5) * covSpacing, 0, width - 1);
      const py = clamp((cy + 0.5) * covSpacing, 0, height - 1);

      const [r, g, b] = sampleRgb(rgba, px, py, width, height);
      const grayVal = sampleGray(gray, px, py, width, height) / 255;
      const dotSize = clamp(Math.round(4 + (1 - grayVal) * 6), 3, 12);

      const c = pixToCanvas(px, py, pixelData);
      const dr = dotSize * 0.3;
      strokes.push(makeStroke(
        [{ x: c.x - dr, y: c.y }, { x: c.x + dr, y: c.y }],
        rgbToHex(r, g, b),
        dotSize,
        0.75,
      ));
    }
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// Main entry — dispatches to mode renderer
// ---------------------------------------------------------------------------

/**
 * Convert pixel data into ClawDraw strokes.
 *
 * @param {object} pixelData
 * @param {Uint8Array} pixelData.rgba    — Raw RGBA bytes (width × height × 4)
 * @param {Uint8Array} pixelData.gray    — Grayscale bytes (width × height)
 * @param {number}     pixelData.width   — Pixel dimensions
 * @param {number}     pixelData.height
 * @param {number}     pixelData.canvasWidth  — Output canvas dimensions
 * @param {number}     pixelData.canvasHeight
 * @param {number}     pixelData.cx      — Canvas center X
 * @param {number}     pixelData.cy      — Canvas center Y
 *
 * @param {object} [options]
 * @param {string} [options.mode='vangogh']  — pointillist | sketch | vangogh | slimemold
 * @param {number} [options.density=1.0]     — Stroke density multiplier
 *
 * @returns {Array} Array of stroke objects
 */
export function traceImage(pixelData, options = {}) {
  const mode = options.mode || 'vangogh';
  switch (mode) {
    case 'pointillist': return renderPointillist(pixelData, options);
    case 'sketch':      return renderSketch(pixelData, options);
    case 'vangogh':     return renderVanGogh(pixelData, options);
    case 'slimemold':   return renderSlimeMold(pixelData, options);
    default:
      throw new Error(`Unknown paint mode: "${mode}". Use pointillist, sketch, vangogh, or slimemold.`);
  }
}

// ---------------------------------------------------------------------------
// Region analysis for freestyle mode
// ---------------------------------------------------------------------------

/**
 * Analyze an image by dividing it into a grid of regions, computing visual
 * characteristics for each cell.  Pure math — uses only existing internal
 * helpers (sampleRgb, sampleGray, pixToCanvas, computeSobel, rgbToHex, clamp).
 *
 * @param {object} pixelData  — Same shape as traceImage input
 * @param {object} [options]
 * @param {number} [options.density=1.0]
 * @returns {Array<{ row, col, cx, cy, cellWidth, cellHeight, color, brightness, edgeDensity, colorVariance, transparent }>}
 */
export function analyzeRegions(pixelData, options = {}) {
  const { rgba, gray, width, height, canvasWidth, canvasHeight } = pixelData;
  const density = options.density || 1.0;
  const aspect = width / height;

  const gridCols = clamp(Math.round(6 * density), 4, 10);
  const gridRows = clamp(Math.round(gridCols / aspect), 4, 10);

  // Compute Sobel once for whole image
  const sobel = computeSobel(gray, width, height);

  // Normalize Sobel magnitudes to 0-1
  let maxMag = 0;
  for (let i = 0; i < sobel.magnitude.length; i++) {
    if (sobel.magnitude[i] > maxMag) maxMag = sobel.magnitude[i];
  }
  const invMax = maxMag > 0 ? 1 / maxMag : 0;

  const cellW = width / gridCols;
  const cellH = height / gridRows;
  const regions = [];

  for (let row = 0; row < gridRows; row++) {
    for (let col = 0; col < gridCols; col++) {
      const x0 = Math.floor(col * cellW);
      const y0 = Math.floor(row * cellH);
      const x1 = Math.min(Math.floor((col + 1) * cellW), width);
      const y1 = Math.min(Math.floor((row + 1) * cellH), height);

      let rSum = 0, gSum = 0, bSum = 0;
      let graySum = 0;
      let edgeCount = 0;
      let transparentCount = 0;
      let pixelCount = 0;

      // First pass: averages
      for (let py = y0; py < y1; py++) {
        for (let px = x0; px < x1; px++) {
          pixelCount++;
          const ai = (py * width + px) * 4 + 3;
          if (rgba[ai] < 128) {
            transparentCount++;
            continue;
          }
          const [r, g, b] = sampleRgb(rgba, px, py, width, height);
          rSum += r;
          gSum += g;
          bSum += b;
          graySum += sampleGray(gray, px, py, width, height);

          const normMag = sobel.magnitude[py * width + px] * invMax;
          if (normMag > 0.15) edgeCount++;
        }
      }

      // Skip transparent cells
      if (transparentCount > pixelCount * 0.5) continue;

      const opaqueCount = pixelCount - transparentCount;
      if (opaqueCount === 0) continue;

      const avgR = rSum / opaqueCount;
      const avgG = gSum / opaqueCount;
      const avgB = bSum / opaqueCount;
      const brightness = (graySum / opaqueCount) / 255;
      const edgeDensity = edgeCount / opaqueCount;

      // Second pass: color variance
      let varR = 0, varG = 0, varB = 0;
      for (let py = y0; py < y1; py++) {
        for (let px = x0; px < x1; px++) {
          const ai = (py * width + px) * 4 + 3;
          if (rgba[ai] < 128) continue;
          const [r, g, b] = sampleRgb(rgba, px, py, width, height);
          varR += (r - avgR) ** 2;
          varG += (g - avgG) ** 2;
          varB += (b - avgB) ** 2;
        }
      }
      const colorVariance = clamp(
        ((varR + varG + varB) / (opaqueCount * 3)) / (255 * 255),
        0, 1,
      );

      // Map cell center to canvas coordinates
      const centerPx = (col + 0.5) * cellW;
      const centerPy = (row + 0.5) * cellH;
      const canvasPos = pixToCanvas(centerPx, centerPy, pixelData);

      regions.push({
        row,
        col,
        cx: canvasPos.x,
        cy: canvasPos.y,
        cellWidth: (cellW / width) * canvasWidth,
        cellHeight: (cellH / height) * canvasHeight,
        color: rgbToHex(Math.round(avgR), Math.round(avgG), Math.round(avgB)),
        brightness,
        edgeDensity,
        colorVariance,
        transparent: false,
      });
    }
  }

  return regions;
}


path: lib/svg-parse.mjs
content: /**
 * SVG path parser — plain JS port of packages/shared/src/utils/svg-parse.ts
 *
 * Parses SVG `d` attribute strings into point arrays.
 */

/** Number of args expected per SVG command */
const ARGS_PER_COMMAND = {
  M: 2, m: 2, L: 2, l: 2, H: 1, h: 1, V: 1, v: 1,
  C: 6, c: 6, S: 4, s: 4, Q: 4, q: 4, T: 2, t: 2,
  A: 7, a: 7, Z: 0, z: 0,
};

/**
 * Tokenize an SVG path `d` attribute into {command, args} pairs.
 * @param {string} d
 * @returns {Array<{command: string, args: number[]}>}
 */
export function tokenizeSvgPath(d) {
  const tokens = [];
  if (!d || d.trim().length === 0) return tokens;

  const re = /([MmLlHhVvCcSsQqTtAaZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
  let current = null;
  let match;

  while ((match = re.exec(d)) !== null) {
    if (match[1]) {
      if (current) tokens.push(current);
      current = { command: match[1], args: [] };
    } else if (match[2]) {
      if (!current) current = { command: 'M', args: [] };
      current.args.push(parseFloat(match[2]));
    }
  }
  if (current) tokens.push(current);

  // Split tokens with too many args into repeated commands
  const result = [];
  for (const token of tokens) {
    const cmd = token.command;
    const expected = ARGS_PER_COMMAND[cmd];
    if (expected === undefined || expected === 0 || token.args.length <= expected) {
      result.push(token);
      continue;
    }
    result.push({ command: cmd, args: token.args.slice(0, expected) });
    const repeatCmd = cmd === 'M' ? 'L' : cmd === 'm' ? 'l' : cmd;
    for (let i = expected; i < token.args.length; i += expected) {
      result.push({ command: repeatCmd, args: token.args.slice(i, i + expected) });
    }
  }
  return result;
}

/**
 * Evaluate a cubic bezier at parameter t.
 * @param {{p0:{x:number,y:number},p1:{x:number,y:number},p2:{x:number,y:number},p3:{x:number,y:number}}} curve
 * @param {number} t
 * @returns {{x:number,y:number}}
 */
function evaluateBezier(curve, t) {
  const mt = 1 - t;
  const mt2 = mt * mt;
  const mt3 = mt2 * mt;
  const t2 = t * t;
  const t3 = t2 * t;
  return {
    x: mt3 * curve.p0.x + 3 * mt2 * t * curve.p1.x + 3 * mt * t2 * curve.p2.x + t3 * curve.p3.x,
    y: mt3 * curve.p0.y + 3 * mt2 * t * curve.p1.y + 3 * mt * t2 * curve.p2.y + t3 * curve.p3.y,
  };
}

/**
 * Compute the angle between two vectors.
 */
function angleBetween(ux, uy, vx, vy) {
  const dot = ux * vx + uy * vy;
  const lenU = Math.sqrt(ux * ux + uy * uy);
  const lenV = Math.sqrt(vx * vx + vy * vy);
  let cos = dot / (lenU * lenV);
  if (cos < -1) cos = -1;
  if (cos > 1) cos = 1;
  const angle = Math.acos(cos);
  return (ux * vy - uy * vx) < 0 ? -angle : angle;
}

/**
 * Sample an SVG arc using center parameterization.
 */
function sampleArc(x1, y1, rxIn, ryIn, phi, largeArc, sweep, x2, y2, numSamples, addPoint) {
  if (rxIn === 0 || ryIn === 0 || (x1 === x2 && y1 === y2)) {
    addPoint(x2, y2);
    return;
  }

  let rx = rxIn;
  let ry = ryIn;
  const cosPhi = Math.cos(phi);
  const sinPhi = Math.sin(phi);

  const dx2 = (x1 - x2) / 2;
  const dy2 = (y1 - y2) / 2;
  const x1p = cosPhi * dx2 + sinPhi * dy2;
  const y1p = -sinPhi * dx2 + cosPhi * dy2;

  const x1pSq = x1p * x1p;
  const y1pSq = y1p * y1p;
  let rxSq = rx * rx;
  let rySq = ry * ry;

  const lambda = x1pSq / rxSq + y1pSq / rySq;
  if (lambda > 1) {
    const sqrtLambda = Math.sqrt(lambda);
    rx *= sqrtLambda;
    ry *= sqrtLambda;
    rxSq = rx * rx;
    rySq = ry * ry;
  }

  let num = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq;
  const den = rxSq * y1pSq + rySq * x1pSq;
  if (num < 0) num = 0;
  let sq = Math.sqrt(num / den);
  if (largeArc === sweep) sq = -sq;

  const cxp = sq * (rx * y1p / ry);
  const cyp = sq * (-(ry * x1p / rx));

  const cxr = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2;
  const cyr = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2;

  const theta1 = angleBetween(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
  let dtheta = angleBetween(
    (x1p - cxp) / rx, (y1p - cyp) / ry,
    (-x1p - cxp) / rx, (-y1p - cyp) / ry,
  );

  if (!sweep && dtheta > 0) dtheta -= 2 * Math.PI;
  if (sweep && dtheta < 0) dtheta += 2 * Math.PI;

  for (let i = 1; i <= numSamples; i++) {
    const t = i / numSamples;
    const angle = theta1 + dtheta * t;
    const cosA = Math.cos(angle);
    const sinA = Math.sin(angle);
    const px = cosPhi * rx * cosA - sinPhi * ry * sinA + cxr;
    const py = sinPhi * rx * cosA + cosPhi * ry * sinA + cyr;
    addPoint(px, py);
  }
}

/**
 * Parse an SVG path `d` attribute string into an array of points.
 *
 * @param {string} d - SVG path string
 * @param {{samplesPerSegment?: number, scale?: number, translate?: {x: number, y: number}}} [options]
 * @returns {Array<{x: number, y: number, pressure: number, timestamp: number}>}
 */
export function parseSvgPath(d, options) {
  const tokens = tokenizeSvgPath(d);
  if (tokens.length === 0) return [];

  const samples = options?.samplesPerSegment ?? 20;
  const scale = options?.scale ?? 1;
  const tx = options?.translate?.x ?? 0;
  const ty = options?.translate?.y ?? 0;

  const points = [];
  let cx = 0, cy = 0;
  let startX = 0, startY = 0;
  let lastCp2X = 0, lastCp2Y = 0;
  let lastCmd = '';

  function addPoint(x, y) {
    points.push({ x: x * scale + tx, y: y * scale + ty, pressure: 0.5, timestamp: 0 });
  }

  function sampleCubic(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3) {
    const curve = { p0: { x: x0, y: y0 }, p1: { x: cp1x, y: cp1y }, p2: { x: cp2x, y: cp2y }, p3: { x: x3, y: y3 } };
    for (let i = 1; i <= samples; i++) {
      const pt = evaluateBezier(curve, i / samples);
      addPoint(pt.x, pt.y);
    }
  }

  for (const token of tokens) {
    const cmd = token.command;
    const args = token.args;
    const isRel = cmd === cmd.toLowerCase() && cmd !== 'Z' && cmd !== 'z';

    switch (cmd.toUpperCase()) {
      case 'M': {
        const x = isRel ? cx + args[0] : args[0];
        const y = isRel ? cy + args[1] : args[1];
        addPoint(x, y);
        cx = x; cy = y; startX = x; startY = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'L': {
        const x = isRel ? cx + args[0] : args[0];
        const y = isRel ? cy + args[1] : args[1];
        addPoint(x, y);
        cx = x; cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'H': {
        const x = isRel ? cx + args[0] : args[0];
        addPoint(x, cy);
        cx = x;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'V': {
        const y = isRel ? cy + args[0] : args[0];
        addPoint(cx, y);
        cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'C': {
        const cp1x = isRel ? cx + args[0] : args[0];
        const cp1y = isRel ? cy + args[1] : args[1];
        const cp2x = isRel ? cx + args[2] : args[2];
        const cp2y = isRel ? cy + args[3] : args[3];
        const x = isRel ? cx + args[4] : args[4];
        const y = isRel ? cy + args[5] : args[5];
        sampleCubic(cx, cy, cp1x, cp1y, cp2x, cp2y, x, y);
        lastCp2X = cp2x; lastCp2Y = cp2y;
        cx = x; cy = y;
        break;
      }
      case 'S': {
        let cp1x, cp1y;
        if ('CcSs'.includes(lastCmd)) {
          cp1x = 2 * cx - lastCp2X;
          cp1y = 2 * cy - lastCp2Y;
        } else {
          cp1x = cx; cp1y = cy;
        }
        const scp2x = isRel ? cx + args[0] : args[0];
        const scp2y = isRel ? cy + args[1] : args[1];
        const sx = isRel ? cx + args[2] : args[2];
        const sy = isRel ? cy + args[3] : args[3];
        sampleCubic(cx, cy, cp1x, cp1y, scp2x, scp2y, sx, sy);
        lastCp2X = scp2x; lastCp2Y = scp2y;
        cx = sx; cy = sy;
        break;
      }
      case 'Q': {
        const qcpx = isRel ? cx + args[0] : args[0];
        const qcpy = isRel ? cy + args[1] : args[1];
        const qx = isRel ? cx + args[2] : args[2];
        const qy = isRel ? cy + args[3] : args[3];
        const c1x = cx + (2 / 3) * (qcpx - cx);
        const c1y = cy + (2 / 3) * (qcpy - cy);
        const c2x = qx + (2 / 3) * (qcpx - qx);
        const c2y = qy + (2 / 3) * (qcpy - qy);
        sampleCubic(cx, cy, c1x, c1y, c2x, c2y, qx, qy);
        lastCp2X = qcpx; lastCp2Y = qcpy;
        cx = qx; cy = qy;
        break;
      }
      case 'T': {
        let qcpx, qcpy;
        if ('QqTt'.includes(lastCmd)) {
          qcpx = 2 * cx - lastCp2X;
          qcpy = 2 * cy - lastCp2Y;
        } else {
          qcpx = cx; qcpy = cy;
        }
        const tx2 = isRel ? cx + args[0] : args[0];
        const ty2 = isRel ? cy + args[1] : args[1];
        const c1x = cx + (2 / 3) * (qcpx - cx);
        const c1y = cy + (2 / 3) * (qcpy - cy);
        const c2x = tx2 + (2 / 3) * (qcpx - tx2);
        const c2y = ty2 + (2 / 3) * (qcpy - ty2);
        sampleCubic(cx, cy, c1x, c1y, c2x, c2y, tx2, ty2);
        lastCp2X = qcpx; lastCp2Y = qcpy;
        cx = tx2; cy = ty2;
        break;
      }
      case 'A': {
        const rx = Math.abs(args[0]);
        const ry = Math.abs(args[1]);
        const xRot = args[2] * (Math.PI / 180);
        const largeArc = args[3] !== 0;
        const sweep = args[4] !== 0;
        const x = isRel ? cx + args[5] : args[5];
        const y = isRel ? cy + args[6] : args[6];
        sampleArc(cx, cy, rx, ry, xRot, largeArc, sweep, x, y, samples, addPoint);
        cx = x; cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'Z': {
        if (cx !== startX || cy !== startY) addPoint(startX, startY);
        cx = startX; cy = startY;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
    }
    lastCmd = cmd;
  }

  return points;
}

/**
 * Parse an SVG path `d` attribute into multiple subpath point arrays.
 * Splits at each M/m command so separate shapes don't get connected.
 *
 * @param {string} d - SVG path string
 * @param {{samplesPerSegment?: number, scale?: number, translate?: {x: number, y: number}}} [options]
 * @returns {Array<Array<{x: number, y: number, pressure: number, timestamp: number}>>}
 */
export function parseSvgPathMulti(d, options) {
  const tokens = tokenizeSvgPath(d);
  if (tokens.length === 0) return [];

  const samples = options?.samplesPerSegment ?? 20;
  const scale = options?.scale ?? 1;
  const tx = options?.translate?.x ?? 0;
  const ty = options?.translate?.y ?? 0;

  const subpaths = [];
  let current = [];
  let cx = 0, cy = 0;
  let startX = 0, startY = 0;
  let lastCp2X = 0, lastCp2Y = 0;
  let lastCmd = '';

  function addPoint(x, y) {
    current.push({ x: x * scale + tx, y: y * scale + ty, pressure: 0.5, timestamp: 0 });
  }

  function sampleCubic(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3) {
    const curve = { p0: { x: x0, y: y0 }, p1: { x: cp1x, y: cp1y }, p2: { x: cp2x, y: cp2y }, p3: { x: x3, y: y3 } };
    for (let i = 1; i <= samples; i++) {
      const pt = evaluateBezier(curve, i / samples);
      addPoint(pt.x, pt.y);
    }
  }

  for (const token of tokens) {
    const cmd = token.command;
    const args = token.args;
    const isRel = cmd === cmd.toLowerCase() && cmd !== 'Z' && cmd !== 'z';

    switch (cmd.toUpperCase()) {
      case 'M': {
        // Start a new subpath — flush the previous one
        if (current.length >= 2) subpaths.push(current);
        current = [];
        const x = isRel ? cx + args[0] : args[0];
        const y = isRel ? cy + args[1] : args[1];
        addPoint(x, y);
        cx = x; cy = y; startX = x; startY = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'L': {
        const x = isRel ? cx + args[0] : args[0];
        const y = isRel ? cy + args[1] : args[1];
        addPoint(x, y);
        cx = x; cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'H': {
        const x = isRel ? cx + args[0] : args[0];
        addPoint(x, cy);
        cx = x;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'V': {
        const y = isRel ? cy + args[0] : args[0];
        addPoint(cx, y);
        cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'C': {
        const cp1x = isRel ? cx + args[0] : args[0];
        const cp1y = isRel ? cy + args[1] : args[1];
        const cp2x = isRel ? cx + args[2] : args[2];
        const cp2y = isRel ? cy + args[3] : args[3];
        const x = isRel ? cx + args[4] : args[4];
        const y = isRel ? cy + args[5] : args[5];
        sampleCubic(cx, cy, cp1x, cp1y, cp2x, cp2y, x, y);
        lastCp2X = cp2x; lastCp2Y = cp2y;
        cx = x; cy = y;
        break;
      }
      case 'S': {
        let cp1x, cp1y;
        if ('CcSs'.includes(lastCmd)) {
          cp1x = 2 * cx - lastCp2X;
          cp1y = 2 * cy - lastCp2Y;
        } else {
          cp1x = cx; cp1y = cy;
        }
        const scp2x = isRel ? cx + args[0] : args[0];
        const scp2y = isRel ? cy + args[1] : args[1];
        const sx = isRel ? cx + args[2] : args[2];
        const sy = isRel ? cy + args[3] : args[3];
        sampleCubic(cx, cy, cp1x, cp1y, scp2x, scp2y, sx, sy);
        lastCp2X = scp2x; lastCp2Y = scp2y;
        cx = sx; cy = sy;
        break;
      }
      case 'Q': {
        const qcpx = isRel ? cx + args[0] : args[0];
        const qcpy = isRel ? cy + args[1] : args[1];
        const qx = isRel ? cx + args[2] : args[2];
        const qy = isRel ? cy + args[3] : args[3];
        const c1x = cx + (2 / 3) * (qcpx - cx);
        const c1y = cy + (2 / 3) * (qcpy - cy);
        const c2x = qx + (2 / 3) * (qcpx - qx);
        const c2y = qy + (2 / 3) * (qcpy - qy);
        sampleCubic(cx, cy, c1x, c1y, c2x, c2y, qx, qy);
        lastCp2X = qcpx; lastCp2Y = qcpy;
        cx = qx; cy = qy;
        break;
      }
      case 'T': {
        let qcpx, qcpy;
        if ('QqTt'.includes(lastCmd)) {
          qcpx = 2 * cx - lastCp2X;
          qcpy = 2 * cy - lastCp2Y;
        } else {
          qcpx = cx; qcpy = cy;
        }
        const tx2 = isRel ? cx + args[0] : args[0];
        const ty2 = isRel ? cy + args[1] : args[1];
        const c1x = cx + (2 / 3) * (qcpx - cx);
        const c1y = cy + (2 / 3) * (qcpy - cy);
        const c2x = tx2 + (2 / 3) * (qcpx - tx2);
        const c2y = ty2 + (2 / 3) * (qcpy - ty2);
        sampleCubic(cx, cy, c1x, c1y, c2x, c2y, tx2, ty2);
        lastCp2X = qcpx; lastCp2Y = qcpy;
        cx = tx2; cy = ty2;
        break;
      }
      case 'A': {
        const rx = Math.abs(args[0]);
        const ry = Math.abs(args[1]);
        const xRot = args[2] * (Math.PI / 180);
        const largeArc = args[3] !== 0;
        const sweep = args[4] !== 0;
        const x = isRel ? cx + args[5] : args[5];
        const y = isRel ? cy + args[6] : args[6];
        sampleArc(cx, cy, rx, ry, xRot, largeArc, sweep, x, y, samples, addPoint);
        cx = x; cy = y;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
      case 'Z': {
        if (cx !== startX || cy !== startY) addPoint(startX, startY);
        cx = startX; cy = startY;
        lastCp2X = cx; lastCp2Y = cy;
        break;
      }
    }
    lastCmd = cmd;
  }

  // Flush final subpath
  if (current.length >= 2) subpaths.push(current);

  return subpaths;
}


path: package.json
content: {
  "name": "@clawdraw/skill",
  "version": "0.9.16",
  "description": "Create algorithmic art on ClawDraw's infinite multiplayer canvas",
  "type": "module",
  "author": "Aaron Lemke",
  "license": "MIT",
  "homepage": "https://clawdraw.ai",
  "repository": {
    "type": "git",
    "url": "https://github.com/kajukabla/clawdraw-skill.git"
  },
  "publishConfig": {
    "access": "public"
  },
  "keywords": [
    "clawdraw",
    "openclaw",
    "art",
    "drawing",
    "canvas",
    "generative",
    "algorithmic",
    "multiplayer"
  ],
  "bin": {
    "clawdraw": "./scripts/clawdraw.mjs"
  },
  "scripts": {
    "postinstall": "node scripts/setup-claude-code.mjs"
  },
  "dependencies": {
    "@cwasm/webp": "^0.1.5",
    "pngjs": "^7.0.0",
    "open": "^11.0.0",
    "sharp": "^0.33.0",
    "ws": "^8.0.0"
  },
  "files": [
    "LICENSE",
    "scripts/clawdraw.mjs",
    "scripts/auth.mjs",
    "scripts/connection.mjs",
    "scripts/snapshot.mjs",
    "scripts/symmetry.mjs",
    "scripts/roam.mjs",
    "primitives/",
    "lib/",
    "templates/",
    "community/",
    "references/",
    "agents/",
    "scripts/setup-claude-code.mjs",
    "SKILL.md",
    "README.md"
  ]
}


path: primitives/3d/cube-3d.mjs
content: /**
 * 3D Cube — wireframe cube with rotation and perspective.
 *
 * Community primitive for ClawDraw.
 */

import { clamp, lerp, makeStroke, samplePalette } from './helpers.mjs';

/** Auto-discovery metadata -- required for registry */
export const METADATA = {
  name: 'cube3d',
  description: 'Wireframe 3D cube with rotation and depth shading',
  category: 'community',
  author: 'kajukabla',
  parameters: {
    cx:            { type: 'number', required: true, description: 'Center X' },
    cy:            { type: 'number', required: true, description: 'Center Y' },
    size:          { type: 'number', required: false, default: 120, description: 'Cube half-size' },
    rotateX:       { type: 'number', required: false, default: 25, description: 'X rotation in degrees' },
    rotateY:       { type: 'number', required: false, default: 35, description: 'Y rotation in degrees' },
    rotateZ:       { type: 'number', required: false, default: 0, description: 'Z rotation in degrees' },
    subdivisions:  { type: 'number', required: false, default: 0, description: 'Edge subdivisions for wireframe detail (0-5)' },
    color:         { type: 'string', required: false, default: '#ffffff', description: 'Hex color (ignored if palette set)' },
    brushSize:     { type: 'number', required: false, default: 4, description: 'Brush width (3-100)' },
    opacity:       { type: 'number', required: false, default: 0.9, description: 'Stroke opacity (0-1)' },
    palette:       { type: 'string', required: false, description: 'Palette name' },
    pressureStyle: { type: 'string', required: false, default: 'default', description: 'Pressure style' },
  },
};

/**
 * Generate a wireframe 3D cube.
 *
 * Computes 8 cube vertices, applies 3-axis rotation, projects to 2D,
 * and draws each of the 12 edges as a stroke. Back edges are drawn
 * at reduced opacity for depth. Optional subdivisions add grid lines
 * on each face.
 *
 * @returns {Array} Array of stroke objects
 */
export function cube3d(cx, cy, size, rotateX, rotateY, rotateZ, subdivisions, color, brushSize, opacity, palette, pressureStyle) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  size = clamp(Number(size) || 120, 10, 500);
  rotateX = (Number(rotateX) || 25) * Math.PI / 180;
  rotateY = (Number(rotateY) || 35) * Math.PI / 180;
  rotateZ = (Number(rotateZ) || 0) * Math.PI / 180;
  subdivisions = clamp(Math.round(Number(subdivisions) || 0), 0, 5);
  brushSize = clamp(Number(brushSize) || 4, 3, 100);
  opacity = clamp(Number(opacity) || 0.9, 0.01, 1);

  const cosX = Math.cos(rotateX), sinX = Math.sin(rotateX);
  const cosY = Math.cos(rotateY), sinY = Math.sin(rotateY);
  const cosZ = Math.cos(rotateZ), sinZ = Math.sin(rotateZ);

  function rotate(x, y, z) {
    // Rotate Z
    let x1 = x * cosZ - y * sinZ;
    let y1 = x * sinZ + y * cosZ;
    // Rotate Y
    let x2 = x1 * cosY - z * sinY;
    let z1 = x1 * sinY + z * cosY;
    // Rotate X
    let y2 = y1 * cosX - z1 * sinX;
    let z2 = y1 * sinX + z1 * cosX;
    return { x: x2, y: y2, z: z2 };
  }

  function project(p) {
    return { x: cx + p.x, y: cy + p.y, z: p.z };
  }

  // 8 cube vertices
  const s = size;
  const rawVerts = [
    [-s, -s, -s], [s, -s, -s], [s, s, -s], [-s, s, -s],
    [-s, -s,  s], [s, -s,  s], [s, s,  s], [-s, s,  s],
  ];
  const verts = rawVerts.map(([x, y, z]) => project(rotate(x, y, z)));

  // 12 edges
  const edges = [
    [0,1],[1,2],[2,3],[3,0], // front face
    [4,5],[5,6],[6,7],[7,4], // back face
    [0,4],[1,5],[2,6],[3,7], // connecting edges
  ];

  const result = [];

  function drawEdge(p0, p1, edgeIndex, totalEdges) {
    const midZ = (p0.z + p1.z) / 2;
    const isFront = midZ >= 0;
    const t = edgeIndex / totalEdges;
    const c = palette ? samplePalette(palette, t) : (color || '#ffffff');
    const edgeOpacity = isFront ? opacity : opacity * 0.35;

    // Add slight wobble for hand-drawn feel
    const wobbleAmt = size * 0.006;
    const pts = [];
    const segs = 8;
    for (let i = 0; i <= segs; i++) {
      const f = i / segs;
      pts.push({
        x: lerp(p0.x, p1.x, f) + (Math.random() - 0.5) * wobbleAmt,
        y: lerp(p0.y, p1.y, f) + (Math.random() - 0.5) * wobbleAmt,
      });
    }
    result.push(makeStroke(pts, c, brushSize, edgeOpacity, pressureStyle));
  }

  // Draw main edges
  for (let i = 0; i < edges.length && result.length < 200; i++) {
    const [a, b] = edges[i];
    drawEdge(verts[a], verts[b], i, edges.length);
  }

  // Subdivisions — grid lines on each face
  if (subdivisions > 0) {
    const faces = [
      [0,1,2,3], [4,5,6,7], // front, back
      [0,1,5,4], [2,3,7,6], // top, bottom
      [0,3,7,4], [1,2,6,5], // left, right
    ];

    for (const face of faces) {
      const [a, b, c, d] = face.map(i => verts[i]);
      for (let sub = 1; sub <= subdivisions && result.length < 200; sub++) {
        const f = sub / (subdivisions + 1);
        // Lines parallel to ab-dc direction
        const p0 = project(rotate(0,0,0)); // dummy to get type
        const s0 = { x: lerp(a.x, d.x, f), y: lerp(a.y, d.y, f), z: lerp(a.z, d.z, f) };
        const s1 = { x: lerp(b.x, c.x, f), y: lerp(b.y, c.y, f), z: lerp(b.z, c.z, f) };
        drawEdge(s0, s1, result.length, 200);

        if (result.length >= 200) break;

        // Lines parallel to ad-bc direction
        const s2 = { x: lerp(a.x, b.x, f), y: lerp(a.y, b.y, f), z: lerp(a.z, b.z, f) };
        const s3 = { x: lerp(d.x, c.x, f), y: lerp(d.y, c.y, f), z: lerp(d.z, c.z, f) };
        drawEdge(s2, s3, result.length, 200);
      }
    }
  }

  return result.slice(0, 200);
}


path: primitives/3d/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/3d/hypercube.mjs
content: /**
 * Hypercube — 4D tesseract wireframe projected to 2D.
 *
 * Community primitive for ClawDraw.
 */

import { clamp, makeStroke, samplePalette } from './helpers.mjs';

export const METADATA = {
  name: 'hypercube',
  description: '4D tesseract wireframe projected to 2D with rotation',
  category: 'community',
  author: 'kajukabla',
  parameters: {
    cx:            { type: 'number', required: true, description: 'Center X' },
    cy:            { type: 'number', required: true, description: 'Center Y' },
    size:          { type: 'number', required: false, default: 150, description: 'Projection scale' },
    angleXW:       { type: 'number', required: false, default: 45, description: 'Rotation angle in XW plane (degrees)' },
    angleYZ:       { type: 'number', required: false, default: 30, description: 'Rotation angle in YZ plane (degrees)' },
    color:         { type: 'string', required: false, default: '#ffffff', description: 'Hex color (ignored if palette set)' },
    brushSize:     { type: 'number', required: false, default: 3, description: 'Brush width (3-100)' },
    opacity:       { type: 'number', required: false, default: 0.85, description: 'Stroke opacity (0-1)' },
    palette:       { type: 'string', required: false, description: 'Palette name (magma, plasma, viridis, turbo, inferno)' },
    pressureStyle: { type: 'string', required: false, default: 'flat', description: 'Pressure style' },
  },
};

/**
 * Generate a 4D hypercube (tesseract) wireframe projected to 2D.
 *
 * 16 vertices (all combinations of +/-1 in 4D), 32 edges (vertices
 * differing in exactly one coordinate). Two rotation planes (XW and YZ)
 * applied before orthographic projection to 2D. Edges colored by
 * average w-depth via palette.
 *
 * @returns {Array} Array of stroke objects
 */
export function hypercube(cx, cy, size, angleXW, angleYZ, color, brushSize, opacity, palette, pressureStyle) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  size = clamp(Number(size) || 150, 20, 500);
  angleXW = (Number(angleXW) || 45) * Math.PI / 180;
  angleYZ = (Number(angleYZ) || 30) * Math.PI / 180;
  brushSize = clamp(Number(brushSize) || 3, 3, 100);
  opacity = clamp(Number(opacity) || 0.85, 0.01, 1);

  // Generate 16 vertices: all combinations of +/-1 in 4D
  const vertices4d = [];
  for (let i = 0; i < 16; i++) {
    vertices4d.push([
      (i & 1) ? 1 : -1,
      (i & 2) ? 1 : -1,
      (i & 4) ? 1 : -1,
      (i & 8) ? 1 : -1,
    ]);
  }

  // Find edges: pairs differing in exactly one coordinate
  const edges = [];
  for (let i = 0; i < 16; i++) {
    for (let j = i + 1; j < 16; j++) {
      let diff = 0;
      for (let k = 0; k < 4; k++) {
        if (vertices4d[i][k] !== vertices4d[j][k]) diff++;
      }
      if (diff === 1) edges.push([i, j]);
    }
  }

  // Apply 4D rotations
  const cosXW = Math.cos(angleXW), sinXW = Math.sin(angleXW);
  const cosYZ = Math.cos(angleYZ), sinYZ = Math.sin(angleYZ);

  // Also rotate in XZ plane for a more interesting 3D view
  const angleXZ = angleYZ * 0.7;
  const cosXZ = Math.cos(angleXZ), sinXZ = Math.sin(angleXZ);

  const projected = vertices4d.map(([x, y, z, w]) => {
    // Rotate in XW plane (4D rotation)
    const x1 = x * cosXW - w * sinXW;
    const w1 = x * sinXW + w * cosXW;
    // Rotate in YZ plane
    const y1 = y * cosYZ - z * sinYZ;
    const z1 = y * sinYZ + z * cosYZ;
    // Rotate in XZ plane for 3D depth
    const x2 = x1 * cosXZ - z1 * sinXZ;
    const z2 = x1 * sinXZ + z1 * cosXZ;
    // Perspective projection from 4D: w affects scale (classic tesseract look)
    const viewDist = 4;
    const perspW = viewDist / (viewDist - w1 * 0.5);
    const perspZ = viewDist / (viewDist - z2 * 0.3);
    const scale = perspW * perspZ;
    return { x: cx + x2 * size * 0.4 * scale, y: cy + y1 * size * 0.4 * scale, w: w1 };
  });

  // Draw edges colored by average w-depth
  const wValues = projected.map(v => v.w);
  const minW = Math.min(...wValues);
  const maxW = Math.max(...wValues);
  const wRange = maxW - minW || 1;

  const result = [];
  for (const [i, j] of edges) {
    const p0 = projected[i];
    const p1 = projected[j];
    const avgW = (p0.w + p1.w) / 2;
    const t = (avgW - minW) / wRange;

    // Subdivide edge for smooth drawing
    const steps = 12;
    const pts = [];
    for (let s = 0; s <= steps; s++) {
      const frac = s / steps;
      pts.push({
        x: p0.x + (p1.x - p0.x) * frac,
        y: p0.y + (p1.y - p0.y) * frac,
      });
    }

    const col = palette ? samplePalette(palette, t) : (color || '#ffffff');
    result.push(makeStroke(pts, col, brushSize, opacity, pressureStyle));
  }

  // Draw vertex dots
  for (let i = 0; i < projected.length && result.length < 200; i++) {
    const v = projected[i];
    const t = (v.w - minW) / wRange;
    const dotR = 3;
    const pts = [];
    for (let s = 0; s <= 8; s++) {
      const a = (s / 8) * Math.PI * 2;
      pts.push({ x: v.x + Math.cos(a) * dotR, y: v.y + Math.sin(a) * dotR });
    }
    const col = palette ? samplePalette(palette, t) : (color || '#ffffff');
    result.push(makeStroke(pts, col, brushSize + 1, opacity, pressureStyle));
  }

  return result.slice(0, 200);
}


path: primitives/3d/sphere-3d.mjs
content: /**
 * 3D Sphere — wireframe sphere with latitude/longitude lines.
 *
 * Community primitive for ClawDraw.
 */

import { clamp, makeStroke, samplePalette } from './helpers.mjs';

/** Auto-discovery metadata -- required for registry */
export const METADATA = {
  name: 'sphere3d',
  description: 'Wireframe 3D sphere with latitude and longitude lines',
  category: 'community',
  author: 'kajukabla',
  parameters: {
    cx:            { type: 'number', required: true, description: 'Center X' },
    cy:            { type: 'number', required: true, description: 'Center Y' },
    radius:        { type: 'number', required: false, default: 120, description: 'Sphere radius' },
    latLines:      { type: 'number', required: false, default: 8, description: 'Latitude lines (2-20)' },
    lonLines:      { type: 'number', required: false, default: 12, description: 'Longitude lines (3-24)' },
    rotateX:       { type: 'number', required: false, default: 20, description: 'X rotation in degrees (-90 to 90)' },
    rotateY:       { type: 'number', required: false, default: 30, description: 'Y rotation in degrees (-180 to 180)' },
    color:         { type: 'string', required: false, default: '#ffffff', description: 'Hex color (ignored if palette set)' },
    brushSize:     { type: 'number', required: false, default: 3, description: 'Brush width (3-100)' },
    opacity:       { type: 'number', required: false, default: 0.85, description: 'Stroke opacity (0-1)' },
    palette:       { type: 'string', required: false, description: 'Palette name' },
    pressureStyle: { type: 'string', required: false, default: 'default', description: 'Pressure style' },
  },
};

/**
 * Generate a wireframe 3D sphere.
 *
 * Draws latitude circles and longitude arcs with 3D rotation,
 * projected to 2D. Back-facing portions are drawn at reduced
 * opacity for depth effect.
 *
 * @returns {Array} Array of stroke objects
 */
export function sphere3d(cx, cy, radius, latLines, lonLines, rotateX, rotateY, color, brushSize, opacity, palette, pressureStyle) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  radius = clamp(Number(radius) || 120, 10, 500);
  latLines = clamp(Math.round(Number(latLines) || 8), 2, 20);
  lonLines = clamp(Math.round(Number(lonLines) || 12), 3, 24);
  rotateX = clamp(Number(rotateX) || 20, -90, 90) * Math.PI / 180;
  rotateY = clamp(Number(rotateY) || 30, -180, 180) * Math.PI / 180;
  brushSize = clamp(Number(brushSize) || 3, 3, 100);
  opacity = clamp(Number(opacity) || 0.85, 0.01, 1);

  const cosRX = Math.cos(rotateX), sinRX = Math.sin(rotateX);
  const cosRY = Math.cos(rotateY), sinRY = Math.sin(rotateY);

  function project(x, y, z) {
    // Rotate around Y axis
    const x1 = x * cosRY - z * sinRY;
    const z1 = x * sinRY + z * cosRY;
    // Rotate around X axis
    const y1 = y * cosRX - z1 * sinRX;
    const z2 = y * sinRX + z1 * cosRX;
    return { x: cx + x1, y: cy + y1, z: z2 };
  }

  const result = [];
  const steps = 36;

  // Latitude lines
  for (let i = 1; i <= latLines && result.length < 200; i++) {
    const phi = (i / (latLines + 1)) * Math.PI;
    const ringR = radius * Math.sin(phi);
    const ringY = -radius * Math.cos(phi);
    const t = phi / Math.PI;
    const c = palette ? samplePalette(palette, t) : (color || '#ffffff');

    const frontPts = [];
    const backPts = [];

    for (let s = 0; s <= steps; s++) {
      const theta = (s / steps) * Math.PI * 2;
      const px = ringR * Math.cos(theta);
      const pz = ringR * Math.sin(theta);
      const p = project(px, ringY, pz);

      if (p.z >= 0) {
        if (backPts.length >= 2) {
          result.push(makeStroke(backPts, c, brushSize, opacity * 0.3, pressureStyle));
          if (result.length >= 200) break;
        }
        frontPts.push({ x: p.x, y: p.y });
        backPts.length = 0;
      } else {
        if (frontPts.length >= 2) {
          result.push(makeStroke(frontPts, c, brushSize, opacity, pressureStyle));
          if (result.length >= 200) break;
        }
        backPts.push({ x: p.x, y: p.y });
        frontPts.length = 0;
      }
    }
    if (frontPts.length >= 2 && result.length < 200) {
      result.push(makeStroke(frontPts, c, brushSize, opacity, pressureStyle));
    }
    if (backPts.length >= 2 && result.length < 200) {
      result.push(makeStroke(backPts, c, brushSize, opacity * 0.3, pressureStyle));
    }
  }

  // Longitude lines
  for (let i = 0; i < lonLines && result.length < 200; i++) {
    const theta = (i / lonLines) * Math.PI * 2;
    const t = (1 - Math.cos(theta)) / 2;
    const c = palette ? samplePalette(palette, t) : (color || '#ffffff');

    const frontPts = [];
    const backPts = [];

    for (let s = 0; s <= steps; s++) {
      const phi = (s / steps) * Math.PI;
      const px = radius * Math.sin(phi) * Math.cos(theta);
      const py = -radius * Math.cos(phi);
      const pz = radius * Math.sin(phi) * Math.sin(theta);
      const p = project(px, py, pz);

      if (p.z >= 0) {
        if (backPts.length >= 2) {
          result.push(makeStroke(backPts, c, brushSize, opacity * 0.3, pressureStyle));
          if (result.length >= 200) break;
        }
        frontPts.push({ x: p.x, y: p.y });
        backPts.length = 0;
      } else {
        if (frontPts.length >= 2) {
          result.push(makeStroke(frontPts, c, brushSize, opacity, pressureStyle));
          if (result.length >= 200) break;
        }
        backPts.push({ x: p.x, y: p.y });
        frontPts.length = 0;
      }
    }
    if (frontPts.length >= 2 && result.length < 200) {
      result.push(makeStroke(frontPts, c, brushSize, opacity, pressureStyle));
    }
    if (backPts.length >= 2 && result.length < 200) {
      result.push(makeStroke(backPts, c, brushSize, opacity * 0.3, pressureStyle));
    }
  }

  return result.slice(0, 200);
}


path: primitives/collaborator.mjs
content: /**
 * Collaborator behaviors — 19 transform primitives that operate on existing strokes.
 *
 * These behaviors look up source stroke(s) from a nearby cache (set by the CLI
 * before calling), apply geometric transforms, and return stroke arrays.
 */

import { makeStroke, splitIntoStrokes, lerpColor, hexToRgb, rgbToHex, noise2d, clipLineToRect, getPressureForStyle, samplePalette, clamp, lerp, hslToHex } from './helpers.mjs';
import { classifyEndpoints, buildDensityMap, detectEnclosedRegions, buildAttractors, detectClosedShapes, pointInPolygon, clipLineToPolygon, buildSDF, shoelaceArea, extractPlanarFaces } from './spatial.mjs';

// ---------------------------------------------------------------------------
// Nearby cache — populated by CLI before calling behaviors
// ---------------------------------------------------------------------------

let _nearbyCache = null;

/** Set the nearby cache data (called by CLI before executing a behavior). */
export function setNearbyCache(data) { _nearbyCache = data; }

/** Get the current nearby cache data. */
export function getNearbyCache() { return _nearbyCache; }

// ---------------------------------------------------------------------------
// Internal geometry helpers (inline since .mjs can't import from @clawdraw/shared)
// ---------------------------------------------------------------------------

function normalizeStrokeId(id) {
  if (id === null || id === undefined) return null;
  const s = String(id).trim();
  if (!s) return null;
  return s.toLowerCase();
}

function buildIdAliases(id) {
  const norm = normalizeStrokeId(id);
  if (!norm) return [];

  const aliases = new Set([norm]);
  const unquoted = norm.replace(/^['"]+|['"]+$/g, '');
  aliases.add(unquoted);

  for (const delim of [':', '/', '|', '@', '#']) {
    if (unquoted.includes(delim)) {
      const parts = unquoted.split(delim).map(p => p.trim()).filter(Boolean);
      if (parts.length > 0) {
        aliases.add(parts[0]);
        aliases.add(parts[parts.length - 1]);
      }
    }
  }

  if (/^\d+$/.test(unquoted)) {
    aliases.add(String(Number(unquoted)));
  }
  const coMatch = unquoted.match(/^co[-_]?(\d+)$/);
  if (coMatch) {
    aliases.add(coMatch[1]);
    aliases.add(`co-${coMatch[1]}`);
    aliases.add(`co_${coMatch[1]}`);
  }

  return [...aliases];
}

function strokeIdCandidates(stroke) {
  const raw = [
    stroke?.id,
    stroke?.strokeId,
    stroke?.sourceId,
    stroke?.originalId,
    stroke?.originId,
    stroke?.clientId,
    stroke?.meta?.id,
    stroke?.meta?.sourceId,
    stroke?.metadata?.id,
    stroke?.metadata?.sourceId,
    stroke?.topology?.id,
    stroke?.topology?.strokeId,
  ];
  const out = new Set();
  for (const value of raw) {
    for (const alias of buildIdAliases(value)) out.add(alias);
  }
  return out;
}

/** Find a stroke by id from the nearby cache. */
function findStrokeById(id, nearCache) {
  if (!nearCache || !nearCache.strokes) return null;
  const strokes = nearCache.strokes;

  const queryRaw = id === null || id === undefined ? '' : String(id);
  // Fast path: exact raw string match.
  for (const s of strokes) {
    if (s && s.id !== undefined && s.id !== null && String(s.id) === queryRaw) return s;
  }

  const queryAliases = buildIdAliases(id);
  if (queryAliases.length === 0) return null;

  // Exact alias matching (unique if possible).
  const exact = [];
  for (const s of strokes) {
    const cand = strokeIdCandidates(s);
    if (queryAliases.some(q => cand.has(q))) exact.push(s);
  }
  if (exact.length === 1) return exact[0];
  if (exact.length > 1) {
    // Deterministic tie-breaker: shortest canonical id wins.
    return exact.slice().sort((a, b) => String(a.id || '').length - String(b.id || '').length)[0];
  }

  // Fallback: unique prefix/suffix match (helps when IDs are wrapped/qualified).
  const fuzzy = [];
  for (const s of strokes) {
    const cand = strokeIdCandidates(s);
    const hit = queryAliases.some(q => {
      if (!q || q.length < 3) return false;
      for (const c of cand) {
        if (c.startsWith(q) || c.endsWith(q)) return true;
      }
      return false;
    });
    if (hit) fuzzy.push(s);
  }
  if (fuzzy.length === 1) return fuzzy[0];
  return null;
}

/** Find the stroke nearest to a point from the nearby cache. */
function findNearestStroke(x, y, nearCache) {
  if (!nearCache || !nearCache.strokes || nearCache.strokes.length === 0) return null;
  let best = null;
  let bestDist = Infinity;
  for (const s of nearCache.strokes) {
    const pts = s.path || s.points || [];
    for (const p of pts) {
      const dx = p.x - x;
      const dy = p.y - y;
      const d = dx * dx + dy * dy;
      if (d < bestDist) {
        bestDist = d;
        best = s;
      }
    }
  }
  return best;
}

/** Find a stroke by id or nearest to a given point. */
function findStroke(idOrPoint, nearCache) {
  if (!nearCache) nearCache = _nearbyCache;
  if (!nearCache) return null;
  if (typeof idOrPoint === 'string' || typeof idOrPoint === 'number') {
    return findStrokeById(idOrPoint, nearCache);
  }
  if (idOrPoint && typeof idOrPoint.x === 'number') {
    return findNearestStroke(idOrPoint.x, idOrPoint.y, nearCache);
  }
  return null;
}

/** Resample a path to exactly n evenly-spaced points. */
function resamplePath(points, n) {
  if (!points || points.length === 0) return [];
  if (points.length === 1 || n <= 1) return [{ ...points[0] }];

  const totalLen = pathLength(points);
  if (totalLen < 1e-6) return points.slice(0, 1).map(p => ({ ...p }));

  const step = totalLen / (n - 1);
  const result = [{ ...points[0] }];
  let dist = 0;
  let segIdx = 0;
  let segDist = 0;

  for (let i = 1; i < n; i++) {
    const target = step * i;
    while (segIdx < points.length - 1) {
      const dx = points[segIdx + 1].x - points[segIdx].x;
      const dy = points[segIdx + 1].y - points[segIdx].y;
      const segLen = Math.sqrt(dx * dx + dy * dy);
      if (dist + segLen - segDist >= target) {
        const remain = target - dist + segDist;
        const t = segLen > 1e-8 ? remain / segLen : 0;
        result.push({
          x: points[segIdx].x + dx * t,
          y: points[segIdx].y + dy * t,
        });
        segDist = remain;
        dist = target;
        break;
      }
      dist += segLen - segDist;
      segDist = 0;
      segIdx++;
    }
    if (result.length <= i) {
      result.push({ ...points[points.length - 1] });
    }
  }
  return result;
}

/** Compute tangent vector at index i in a point array. */
function tangentAt(points, i) {
  const n = points.length;
  if (n < 2) return { x: 1, y: 0 };
  let dx, dy;
  if (i <= 0) {
    dx = points[1].x - points[0].x;
    dy = points[1].y - points[0].y;
  } else if (i >= n - 1) {
    dx = points[n - 1].x - points[n - 2].x;
    dy = points[n - 1].y - points[n - 2].y;
  } else {
    dx = points[i + 1].x - points[i - 1].x;
    dy = points[i + 1].y - points[i - 1].y;
  }
  const len = Math.sqrt(dx * dx + dy * dy);
  if (len < 1e-8) return { x: 1, y: 0 };
  return { x: dx / len, y: dy / len };
}

/** Compute normal vector (perpendicular to tangent) at index i. */
function normalAt(points, i) {
  const t = tangentAt(points, i);
  return { x: -t.y, y: t.x };
}

/** Offset all points by dx, dy. */
function offsetPath(points, dx, dy) {
  return points.map(p => ({ x: p.x + dx, y: p.y + dy }));
}

/** Rotate all points by angle (radians) around an origin. */
function rotatePath(points, angle, origin) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  const ox = origin.x || 0;
  const oy = origin.y || 0;
  return points.map(p => {
    const dx = p.x - ox;
    const dy = p.y - oy;
    return { x: ox + dx * cos - dy * sin, y: oy + dx * sin + dy * cos };
  });
}

/** Scale all points by factor around an origin. */
function scalePath(points, factor, origin) {
  const ox = origin.x || 0;
  const oy = origin.y || 0;
  return points.map(p => ({
    x: ox + (p.x - ox) * factor,
    y: oy + (p.y - oy) * factor,
  }));
}

/** Mirror a path across an axis at a given position. */
function mirrorPath(points, axis, position) {
  return points.map(p => {
    if (axis === 'vertical') {
      return { x: 2 * position - p.x, y: p.y };
    }
    return { x: p.x, y: 2 * position - p.y };
  });
}

/** Compute total path length. */
function pathLength(points) {
  let len = 0;
  for (let i = 1; i < points.length; i++) {
    const dx = points[i].x - points[i - 1].x;
    const dy = points[i].y - points[i - 1].y;
    len += Math.sqrt(dx * dx + dy * dy);
  }
  return len;
}

/** Compute centroid of a point array. */
function centroid(points) {
  if (!points || points.length === 0) return { x: 0, y: 0 };
  let sx = 0, sy = 0;
  for (const p of points) { sx += p.x; sy += p.y; }
  return { x: sx / points.length, y: sy / points.length };
}

/** Simple easing functions. */
function applyEasing(t, easing) {
  switch (easing) {
    case 'ease-in': return t * t;
    case 'ease-out': return t * (2 - t);
    case 'ease-in-out': return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
    default: return t; // linear
  }
}

/** Extract points from a nearby-cache stroke object. */
function getStrokePoints(s) {
  if (!s) return [];
  // nearby API returns geometry in `path`, raw strokes use `points`
  return (s.path || s.points || []).map(p => ({ x: p.x, y: p.y }));
}

/** Get stroke color from nearby-cache stroke. */
function getStrokeColor(s) {
  if (!s) return '#ffffff';
  return s.brush?.color || s.color || '#ffffff';
}

/** Get stroke brush size from nearby-cache stroke. */
function getStrokeBrushSize(s) {
  if (!s) return 5;
  return s.brush?.size || s.brushSize || 5;
}

/** Get stroke opacity. */
function getStrokeOpacity(s) {
  if (!s) return 0.9;
  return s.brush?.opacity || s.opacity || 0.9;
}

/** Darken a hex color by a factor (0-1). */
function darkenColor(hex, amount) {
  const c = hexToRgb(hex);
  return rgbToHex(
    Math.round(c.r * (1 - amount)),
    Math.round(c.g * (1 - amount)),
    Math.round(c.b * (1 - amount)),
  );
}

/** Compute the convex hull of a set of 2D points (Andrew's monotone chain). */
function convexHull(points) {
  if (points.length <= 1) return points.slice();
  const sorted = points.slice().sort((a, b) => a.x - b.x || a.y - b.y);
  const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  const lower = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
    lower.push(p);
  }
  const upper = [];
  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
    upper.push(p);
  }
  lower.pop();
  upper.pop();
  return lower.concat(upper);
}

function normalizeVec(x, y, fallback = { x: 1, y: 0 }) {
  const len = Math.sqrt(x * x + y * y);
  if (len < 1e-8) return { x: fallback.x, y: fallback.y };
  return { x: x / len, y: y / len };
}

function rgbToHsl(r, g, b) {
  r /= 255;
  g /= 255;
  b /= 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
    else if (max === g) h = ((b - r) / d + 2) / 6;
    else h = ((r - g) / d + 4) / 6;
  }
  return { h: h * 360, s: s * 100, l: l * 100 };
}

function shapeBounds(polygon) {
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;
  for (const p of polygon) {
    if (p.x < minX) minX = p.x;
    if (p.y < minY) minY = p.y;
    if (p.x > maxX) maxX = p.x;
    if (p.y > maxY) maxY = p.y;
  }
  return { minX, minY, maxX, maxY };
}

function collectSurfaceShapes(nc, bounds, maxShapes = 64) {
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  let rawShapes = [];
  if (nc.topology) {
    rawShapes = detectClosedShapes(nc);
  }
  if (!rawShapes || rawShapes.length === 0) {
    rawShapes = extractPlanarFaces(nc.strokes, bounds);
  }

  const normalized = [];
  for (const shape of rawShapes) {
    const polygon = (shape.polygon || []).map((p) => ({ x: p.x, y: p.y }));
    if (polygon.length < 3) continue;
    const bbox = shapeBounds(polygon);
    const area = Math.max(0, Number(shape.area) || shoelaceArea(polygon));
    if (!isFinite(area) || area < 16) continue;
    const c =
      shape.centroid && isFinite(shape.centroid.x) && isFinite(shape.centroid.y)
        ? { x: shape.centroid.x, y: shape.centroid.y }
        : centroid(polygon);
    normalized.push({
      polygon,
      centroid: c,
      area,
      strokeIds: Array.isArray(shape.strokeIds) ? shape.strokeIds : [],
      bbox,
    });
  }

  normalized.sort((a, b) => b.area - a.area);
  return normalized.slice(0, maxShapes);
}

function pointInShapeFast(shape, x, y) {
  const bb = shape.bbox;
  if (!bb || x < bb.minX || x > bb.maxX || y < bb.minY || y > bb.maxY) return false;
  return pointInPolygon(x, y, shape.polygon);
}

function buildSurfaceField(strokes, bounds, shapes, resolution = 120) {
  const sdf = buildSDF(strokes, bounds, resolution);
  const probe = Math.max(
    (bounds.maxX - bounds.minX) / resolution,
    (bounds.maxY - bounds.minY) / resolution,
    1,
  );

  function isInsideClosed(x, y) {
    for (const shape of shapes) {
      if (pointInShapeFast(shape, x, y)) return true;
    }
    return false;
  }

  function unsignedDistance(x, y) {
    const d = Math.abs(sdf.query(x, y));
    return Number.isFinite(d) ? d : Infinity;
  }

  function signedDistance(x, y) {
    const d = unsignedDistance(x, y);
    if (!isFinite(d)) return d;
    return isInsideClosed(x, y) ? -d : d;
  }

  function normal(x, y) {
    const g = sdf.gradient(x, y);
    const n = normalizeVec(g.x, g.y, { x: 0, y: 0 });
    if (Math.abs(n.x) + Math.abs(n.y) < 1e-8) return { x: 0, y: 0 };

    // Orient toward increasing signed distance (outward from closed regions).
    const fwd = signedDistance(x + n.x * probe, y + n.y * probe);
    const bwd = signedDistance(x - n.x * probe, y - n.y * probe);
    return fwd >= bwd ? n : { x: -n.x, y: -n.y };
  }

  function tangent(x, y) {
    const n = normal(x, y);
    return { x: -n.y, y: n.x };
  }

  return {
    resolution,
    unsignedDistance,
    signedDistance,
    isInsideClosed,
    normal,
    tangent,
  };
}

function buildSurfaceSeeds(
  nc,
  nearX,
  nearY,
  radius,
  surfaceShapes,
  surfaceField,
  maxSeeds = 24,
  style = {},
) {
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  const candidates = [];
  const radius2 = (radius * 1.45) * (radius * 1.45);
  const addCandidate = (cand) => {
    if (!cand) return;
    if (!isFinite(cand.x) || !isFinite(cand.y)) return;
    if (!isFinite(cand.dir?.x) || !isFinite(cand.dir?.y)) return;
    const dx = cand.x - nearX;
    const dy = cand.y - nearY;
    if (dx * dx + dy * dy > radius2) return;
    candidates.push(cand);
  };

  // Exterior endpoint attractors
  const attractors = buildAttractors(nc.strokes, Math.max(maxSeeds * 3, 24));
  for (const attr of attractors) {
    const dir = normalizeVec(attr.direction?.x || 0, attr.direction?.y || 0, { x: 1, y: 0 });
    const src = findNearestStroke(attr.x, attr.y, nc);
    addCandidate({
      x: attr.x + dir.x * 2,
      y: attr.y + dir.y * 2,
      dir,
      strength: 0.55 + clamp(attr.strength || 0, 0, 1) * 0.55,
      sourceStroke: src,
    });
  }

  // Richer endpoint classification (captures open-space edge direction)
  const endpointInfo = classifyEndpoints(nc.strokes, clamp(radius * 0.22, 45, 130));
  for (const ep of endpointInfo.exterior || []) {
    let dir = normalizeVec(
      ep.growthDir?.x || 0,
      ep.growthDir?.y || 0,
      { x: Math.cos(ep.angle || 0), y: Math.sin(ep.angle || 0) },
    );
    if (Math.abs(dir.x) + Math.abs(dir.y) < 1e-8) {
      const n = surfaceField.normal(ep.x, ep.y);
      dir = normalizeVec(n.x, n.y);
    }
    const src = findStrokeById(ep.strokeId, nc) || findNearestStroke(ep.x, ep.y, nc);
    addCandidate({
      x: ep.x + dir.x * 2,
      y: ep.y + dir.y * 2,
      dir,
      strength: 0.7 + clamp((ep.emptySectors - 3) / 5, 0, 1) * 0.45,
      sourceStroke: src,
    });
  }

  // Closed-shape boundaries (grows out of surfaces, not just endpoints)
  const areaNorm = Math.max(1, Math.PI * radius * radius * 0.25);
  for (const shape of surfaceShapes) {
    const poly = shape.polygon;
    if (!poly || poly.length < 3) continue;
    const samples = clamp(Math.round(Math.sqrt(shape.area) / 26), 3, 14);
    const step = Math.max(1, Math.floor(poly.length / samples));
    const shapeStrength = clamp(shape.area / areaNorm, 0, 1);

    for (let i = 0; i < poly.length; i += step) {
      const p = poly[i];
      let n = surfaceField.normal(p.x, p.y);
      if (Math.abs(n.x) + Math.abs(n.y) < 1e-8) {
        n = normalizeVec(p.x - shape.centroid.x, p.y - shape.centroid.y, { x: 1, y: 0 });
      }
      const src =
        (shape.strokeIds && shape.strokeIds.length > 0 ? findStrokeById(shape.strokeIds[0], nc) : null)
        || findNearestStroke(p.x, p.y, nc);

      addCandidate({
        x: p.x + n.x * 2.5,
        y: p.y + n.y * 2.5,
        dir: n,
        strength: 0.85 + shapeStrength * 0.4,
        sourceStroke: src,
      });
    }
  }

  // Fallback: endpoint sampling if all topology cues fail
  if (candidates.length === 0) {
    for (const stroke of nc.strokes) {
      const pts = getStrokePoints(stroke);
      if (pts.length < 2) continue;
      const startDir = normalizeVec(pts[0].x - pts[1].x, pts[0].y - pts[1].y, { x: 1, y: 0 });
      const endDir = normalizeVec(
        pts[pts.length - 1].x - pts[pts.length - 2].x,
        pts[pts.length - 1].y - pts[pts.length - 2].y,
        { x: 1, y: 0 },
      );
      addCandidate({
        x: pts[0].x + startDir.x * 2,
        y: pts[0].y + startDir.y * 2,
        dir: startDir,
        strength: 0.5,
        sourceStroke: stroke,
      });
      addCandidate({
        x: pts[pts.length - 1].x + endDir.x * 2,
        y: pts[pts.length - 1].y + endDir.y * 2,
        dir: endDir,
        strength: 0.5,
        sourceStroke: stroke,
      });
      if (candidates.length >= maxSeeds * 3) break;
    }
  }

  candidates.sort((a, b) => b.strength - a.strength);
  const minSeedDist = clamp(radius * 0.12, 18, 64);
  const minSeedDist2 = minSeedDist * minSeedDist;
  const seeds = [];

  for (const cand of candidates) {
    let tooClose = false;
    for (const s of seeds) {
      const dx = cand.x - s.x;
      const dy = cand.y - s.y;
      if (dx * dx + dy * dy < minSeedDist2) {
        tooClose = true;
        break;
      }
    }
    if (tooClose) continue;

    const srcColor = cand.sourceStroke ? getStrokeColor(cand.sourceStroke) : (nc.summary?.palette?.[0] || '#ffffff');
    const srcSize = cand.sourceStroke ? getStrokeBrushSize(cand.sourceStroke) : 5;
    seeds.push({
      x: cand.x,
      y: cand.y,
      dir: normalizeVec(cand.dir.x, cand.dir.y),
      strength: cand.strength,
      color: style.colorOverride || srcColor,
      brushSize: style.brushOverride || clamp(srcSize * (style.brushScale || 0.8), 1, 20),
    });
    if (seeds.length >= maxSeeds) break;
  }

  return seeds;
}

function buildStrokeField(nc, bounds, sampleSpacing = 10, ignoreStrokeId = null) {
  const empty = {
    sampleCount: 0,
    influence: () => null,
    steerAlongStroke: () => ({ x: 0, y: 0, error: 0, info: null }),
    styleAt: (x, y, fallbackColor = '#ffffff', fallbackSize = 4, fallbackOpacity = 0.9) => ({
      color: fallbackColor,
      brushSize: fallbackSize,
      opacity: fallbackOpacity,
      info: null,
    }),
  };
  if (!nc || !nc.strokes || nc.strokes.length === 0) return empty;

  const spacing = clamp(Number(sampleSpacing) || 10, 4, 40);
  const cellSize = clamp(spacing * 2.6, 10, 120);
  const maxSamples = 14000;
  const samples = [];
  const grid = new Map();

  function cellKey(gx, gy) {
    return `${gx},${gy}`;
  }

  function addSample(sample) {
    const idx = samples.length;
    samples.push(sample);
    const gx = Math.floor(sample.x / cellSize);
    const gy = Math.floor(sample.y / cellSize);
    const key = cellKey(gx, gy);
    let bucket = grid.get(key);
    if (!bucket) {
      bucket = [];
      grid.set(key, bucket);
    }
    bucket.push(idx);
  }

  for (const stroke of nc.strokes) {
    if (!stroke) continue;
    if (ignoreStrokeId && stroke.id && stroke.id === ignoreStrokeId) continue;
    const pts = getStrokePoints(stroke);
    if (pts.length < 2) continue;

    if (bounds) {
      let touches = false;
      for (const p of pts) {
        if (p.x >= bounds.minX && p.x <= bounds.maxX && p.y >= bounds.minY && p.y <= bounds.maxY) {
          touches = true;
          break;
        }
      }
      if (!touches) continue;
    }

    const len = pathLength(pts);
    if (len < 1e-3) continue;
    const nSamples = clamp(Math.round(len / spacing), 8, 240);
    const resampled = resamplePath(pts, nSamples);
    for (let i = 0; i < resampled.length; i++) {
      if (samples.length >= maxSamples) break;
      const p = resampled[i];
      const tang = tangentAt(resampled, i);
      const norm = { x: -tang.y, y: tang.x };
      addSample({
        x: p.x,
        y: p.y,
        tangent: tang,
        normal: norm,
        stroke,
      });
    }
    if (samples.length >= maxSamples) break;
  }

  if (samples.length === 0) return empty;

  function gatherCandidateIndices(x, y, maxRadius) {
    const gx = Math.floor(x / cellSize);
    const gy = Math.floor(y / cellSize);
    const rCells = Math.max(1, Math.ceil(maxRadius / cellSize));
    const out = [];
    for (let dx = -rCells; dx <= rCells; dx++) {
      for (let dy = -rCells; dy <= rCells; dy++) {
        const bucket = grid.get(cellKey(gx + dx, gy + dy));
        if (!bucket) continue;
        for (const idx of bucket) out.push(idx);
      }
    }
    return out;
  }

  function influence(x, y, heading = null, maxRadius = 80) {
    const radius = Math.max(1, maxRadius);
    const r2 = radius * radius;
    const candidates = gatherCandidateIndices(x, y, radius);
    if (candidates.length === 0) return null;

    const hasHeading = heading && isFinite(heading.x) && isFinite(heading.y);
    const h = hasHeading ? normalizeVec(heading.x, heading.y, { x: 1, y: 0 }) : null;
    let sumW = 0;
    let sumX = 0;
    let sumY = 0;
    let sumTx = 0;
    let sumTy = 0;
    let nearest = null;
    let nearestD2 = Infinity;

    for (const idx of candidates) {
      const s = samples[idx];
      const dx = x - s.x;
      const dy = y - s.y;
      const d2 = dx * dx + dy * dy;
      if (d2 > r2) continue;
      if (d2 < nearestD2) {
        nearestD2 = d2;
        nearest = s;
      }
      const d = Math.sqrt(d2);
      const w = 1 / (1 + d * 0.42);
      sumW += w;
      sumX += s.x * w;
      sumY += s.y * w;
      let tx = s.tangent.x;
      let ty = s.tangent.y;
      if (h && tx * h.x + ty * h.y < 0) {
        tx = -tx;
        ty = -ty;
      }
      sumTx += tx * w;
      sumTy += ty * w;
    }

    if (sumW < 1e-8 || !nearest || !isFinite(nearestD2)) return null;
    const centerX = sumX / sumW;
    const centerY = sumY / sumW;
    const tangent = normalizeVec(
      sumTx,
      sumTy,
      nearest ? nearest.tangent : { x: 1, y: 0 },
    );
    let nx = x - centerX;
    let ny = y - centerY;
    const nLen = Math.sqrt(nx * nx + ny * ny);
    const normal = nLen > 1e-6
      ? { x: nx / nLen, y: ny / nLen }
      : normalizeVec(nearest.normal.x, nearest.normal.y, { x: -tangent.y, y: tangent.x });

    return {
      dist: Math.sqrt(nearestD2),
      tangent,
      normal,
      center: { x: centerX, y: centerY },
      nearest,
      weight: sumW,
    };
  }

  function steerAlongStroke(
    x,
    y,
    heading,
    targetDist = 14,
    maxRadius = 84,
    tangentWeight = 1,
    normalWeight = 0.75,
  ) {
    const info = influence(x, y, heading, maxRadius);
    if (!info) return { x: 0, y: 0, error: 0, info: null };
    const desired = Math.max(1, targetDist);
    const err = clamp((desired - info.dist) / desired, -1, 1);
    return {
      x: info.tangent.x * tangentWeight + info.normal.x * err * normalWeight,
      y: info.tangent.y * tangentWeight + info.normal.y * err * normalWeight,
      error: err,
      info,
    };
  }

  function styleAt(x, y, fallbackColor = '#ffffff', fallbackSize = 4, fallbackOpacity = 0.9, maxRadius = 80) {
    const info = influence(x, y, null, maxRadius);
    if (!info || !info.nearest || !info.nearest.stroke) {
      return { color: fallbackColor, brushSize: fallbackSize, opacity: fallbackOpacity, info: null };
    }
    const src = info.nearest.stroke;
    return {
      color: getStrokeColor(src) || fallbackColor,
      brushSize: getStrokeBrushSize(src) || fallbackSize,
      opacity: getStrokeOpacity(src) || fallbackOpacity,
      info,
    };
  }

  return {
    sampleCount: samples.length,
    influence,
    steerAlongStroke,
    styleAt,
  };
}

// ---------------------------------------------------------------------------
// Metadata for registry auto-discovery
// ---------------------------------------------------------------------------

export const METADATA = [
  // STRUCTURAL
  {
    name: 'extend', description: 'Continue from an endpoint in its direction', category: 'collaborator',
    parameters: {
      from: { type: 'string', required: true, description: 'Source stroke ID' },
      endpoint: { type: 'string', default: 'end', options: ['start', 'end'], description: 'Which endpoint' },
      length: { type: 'number', default: 200, min: 10, max: 2000, description: 'Extension length' },
      curve: { type: 'number', default: 0, min: 0, max: 1, description: 'Curve amount toward target' },
      curveTowardX: { type: 'number', description: 'Curve target X' },
      curveTowardY: { type: 'number', description: 'Curve target Y' },
    },
  },
  {
    name: 'branch', description: 'Fork from an endpoint at an angle', category: 'collaborator',
    parameters: {
      from: { type: 'string', required: true, description: 'Source stroke ID' },
      endpoint: { type: 'string', default: 'end', options: ['start', 'end'], description: 'Which endpoint' },
      angle: { type: 'number', default: 45, description: 'Branch angle in degrees' },
      length: { type: 'number', default: 150, min: 10, max: 1000, description: 'Branch length' },
      taper: { type: 'boolean', default: true, description: 'Taper the branch' },
      count: { type: 'number', default: 3, min: 1, max: 10, description: 'Number of branches' },
    },
  },
  {
    name: 'connect', description: 'Bridge two nearest unconnected endpoints', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 500, description: 'Search radius' },
      style: { type: 'string', default: 'blend', options: ['blend', 'match-a', 'match-b'], description: 'Color style' },
      curve: { type: 'number', default: 0.3, min: 0, max: 1, description: 'Curve amount' },
    },
  },
  {
    name: 'coil', description: 'Spiral around a stroke path', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      loops: { type: 'number', default: 6, min: 1, max: 30, description: 'Number of coil loops' },
      radius: { type: 'number', default: 25, min: 2, max: 100, description: 'Coil radius' },
      taper: { type: 'boolean', default: true, description: 'Taper the coil' },
      direction: { type: 'string', default: 'cw', options: ['cw', 'ccw'], description: 'Coil direction' },
    },
  },
  // FILLING
  {
    name: 'morph', description: 'Blend between two strokes (generates many intermediate strokes)', category: 'collaborator',
    parameters: {
      from: { type: 'string', required: true, description: 'Source stroke ID A' },
      to: { type: 'string', required: true, description: 'Source stroke ID B' },
      steps: { type: 'number', default: 15, min: 2, max: 50, description: 'Number of intermediate strokes' },
      easing: { type: 'string', default: 'linear', options: ['linear', 'ease-in', 'ease-out', 'ease-in-out'], description: 'Easing function' },
    },
  },
  {
    name: 'hatchGradient', description: 'Hatching with density gradient (fills a region with many hatch lines)', category: 'collaborator',
    parameters: {
      x: { type: 'number', required: true, description: 'Region X' },
      y: { type: 'number', required: true, description: 'Region Y' },
      w: { type: 'number', required: true, default: 300, description: 'Region width' },
      h: { type: 'number', required: true, default: 300, description: 'Region height' },
      angle: { type: 'number', default: 45, description: 'Hatch angle in degrees' },
      spacingFrom: { type: 'number', default: 5, min: 3, max: 50, description: 'Min spacing (dense)' },
      spacingTo: { type: 'number', default: 15, min: 5, max: 100, description: 'Max spacing (sparse)' },
      gradientDirection: { type: 'string', default: 'along', options: ['along', 'across'], description: 'Gradient direction' },
      color: { type: 'string', default: '#ffffff', description: 'Hatch color' },
      brushSize: { type: 'number', default: 3, description: 'Brush size' },
    },
  },
  {
    name: 'stitch', description: 'Short perpendicular marks along a path', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      spacing: { type: 'number', default: 8, min: 3, max: 100, description: 'Stitch spacing (smaller = more stitches)' },
      length: { type: 'number', default: 15, min: 3, max: 100, description: 'Stitch length' },
      alternating: { type: 'boolean', default: true, description: 'Alternate stitch direction' },
    },
  },
  {
    name: 'bloom', description: 'Radiate many strokes outward from a point', category: 'collaborator',
    parameters: {
      atX: { type: 'number', required: true, description: 'Center X' },
      atY: { type: 'number', required: true, description: 'Center Y' },
      count: { type: 'number', default: 24, min: 3, max: 120, description: 'Number of rays' },
      length: { type: 'number', default: 120, min: 10, max: 1000, description: 'Ray length' },
      spread: { type: 'number', default: 360, min: 10, max: 360, description: 'Spread angle in degrees' },
      taper: { type: 'boolean', default: true, description: 'Taper the rays' },
      noise: { type: 'number', default: 0.2, min: 0, max: 1, description: 'Direction/length noise' },
      color: { type: 'string', default: '#ffffff', description: 'Ray color' },
      brushSize: { type: 'number', default: 4, description: 'Brush size' },
    },
  },
  // COPY/TRANSFORM
  {
    name: 'gradient', description: 'Progressive color/offset copies (many copies along a direction)', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      count: { type: 'number', default: 10, min: 2, max: 40, description: 'Number of copies' },
      offsetX: { type: 'number', default: 8, description: 'X offset per copy' },
      offsetY: { type: 'number', default: 0, description: 'Y offset per copy' },
      colorFrom: { type: 'string', description: 'Starting color (default: source color)' },
      colorTo: { type: 'string', description: 'Ending color' },
      sizeFrom: { type: 'number', description: 'Starting brush size' },
      sizeTo: { type: 'number', description: 'Ending brush size' },
    },
  },
  {
    name: 'parallel', description: 'Offset copies perpendicular to path', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      count: { type: 'number', default: 8, min: 1, max: 30, description: 'Number of copies' },
      spacing: { type: 'number', default: 6, min: 1, max: 100, description: 'Spacing between copies' },
      colorShift: { type: 'string', description: 'Color for copies' },
      bothSides: { type: 'boolean', default: true, description: 'Create copies on both sides' },
    },
  },
  {
    name: 'echo', description: 'Scaled + faded ripple copies', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      count: { type: 'number', default: 6, min: 1, max: 15, description: 'Number of echoes' },
      scaleEach: { type: 'number', default: 1.12, min: 0.5, max: 2, description: 'Scale factor per echo' },
      opacityEach: { type: 'number', default: 0.75, min: 0.1, max: 1, description: 'Opacity multiplier per echo' },
      noise: { type: 'number', default: 0.1, min: 0, max: 1, description: 'Position noise' },
    },
  },
  {
    name: 'cascade', description: 'Shrinking rotated copies (fractal fan)', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      count: { type: 'number', default: 8, min: 2, max: 20, description: 'Number of copies' },
      scaleEach: { type: 'number', default: 0.8, min: 0.3, max: 1, description: 'Scale factor per copy' },
      rotateEach: { type: 'number', default: 20, description: 'Rotation per copy in degrees' },
      anchor: { type: 'string', default: 'end', options: ['start', 'end', 'center'], description: 'Rotation anchor' },
    },
  },
  {
    name: 'mirror', description: 'Reflect across an axis', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      axis: { type: 'string', default: 'vertical', options: ['horizontal', 'vertical'], description: 'Mirror axis' },
      offset: { type: 'number', default: 0, description: 'Axis offset from centroid' },
      opacity: { type: 'number', default: 1, min: 0.01, max: 1, description: 'Mirror opacity' },
      colorShift: { type: 'string', description: 'Override color for mirrored copy' },
    },
  },
  {
    name: 'shadow', description: 'Darker, thicker, offset copy', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      offsetX: { type: 'number', default: 5, description: 'Shadow X offset' },
      offsetY: { type: 'number', default: 5, description: 'Shadow Y offset' },
      darken: { type: 'number', default: 0.4, min: 0, max: 1, description: 'Darken amount' },
      opacity: { type: 'number', default: 0.5, min: 0.01, max: 1, description: 'Shadow opacity' },
      blur: { type: 'number', default: 0.3, min: 0, max: 1, description: 'Blur (size increase)' },
    },
  },
  // REACTIVE
  {
    name: 'counterpoint', description: 'Inverse shape (peaks become valleys)', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      offsetX: { type: 'number', default: 0, description: 'X offset' },
      offsetY: { type: 'number', default: 30, description: 'Y offset' },
      amplitude: { type: 'number', default: 1, min: 0.1, max: 5, description: 'Inversion amplitude' },
      invertX: { type: 'boolean', default: false, description: 'Also invert X deviations' },
    },
  },
  {
    name: 'harmonize', description: 'Continue detected pattern of nearby strokes', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 300, description: 'Search radius' },
      count: { type: 'number', default: 3, min: 1, max: 10, description: 'Strokes to generate' },
      directionX: { type: 'number', description: 'Force direction X (auto if omitted)' },
      directionY: { type: 'number', description: 'Force direction Y (auto if omitted)' },
    },
  },
  {
    name: 'fragment', description: 'Break stroke into scattered segments', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      pieces: { type: 'number', default: 5, min: 2, max: 20, description: 'Number of pieces' },
      scatter: { type: 'number', default: 30, min: 0, max: 200, description: 'Scatter radius' },
      opacityDecay: { type: 'number', default: 0.15, min: 0, max: 1, description: 'Opacity decay per piece' },
    },
  },
  {
    name: 'outline', description: 'Contour around stroke cluster', category: 'collaborator',
    parameters: {
      strokes: { type: 'string', required: true, description: 'Comma-separated stroke IDs' },
      padding: { type: 'number', default: 20, min: 0, max: 200, description: 'Outline padding' },
      style: { type: 'string', default: 'convex', options: ['convex', 'tight'], description: 'Hull style' },
      color: { type: 'string', description: 'Outline color' },
      brushSize: { type: 'number', default: 3, description: 'Brush size' },
    },
  },
  // SHADING
  {
    name: 'contour', description: 'Light-aware form-following hatching', category: 'collaborator',
    parameters: {
      source: { type: 'string', required: true, description: 'Source stroke ID' },
      lightAngle: { type: 'number', default: 315, description: 'Light direction in degrees (315 = upper-left)' },
      style: { type: 'string', default: 'hatch', options: ['hatch', 'crosshatch'], description: 'Hatching style' },
      layers: { type: 'number', default: 1, min: 1, max: 3, description: 'Number of hatch layers' },
      intensity: { type: 'number', default: 0.7, min: 0, max: 1, description: 'Shading intensity' },
    },
  },
  // SPATIAL
  {
    name: 'physarum', description: 'Grow slime-mold trails guided by exterior attractors and SDF surface tangents', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 300, min: 50, max: 2000, description: 'Search radius' },
      agents: { type: 'number', default: 30, min: 5, max: 100, description: 'Number of virtual agents' },
      steps: { type: 'number', default: 50, min: 10, max: 200, description: 'Simulation steps' },
      trailWidth: { type: 'number', default: 3, min: 1, max: 15, description: 'Trail stroke width' },
      color: { type: 'string', default: '#ffffff', description: 'Trail color' },
    },
  },
  {
    name: 'attractorBranch', description: 'Grow SDF-guided fractal trees outward from detected surfaces', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 200, min: 50, max: 1000, description: 'Search radius' },
      length: { type: 'number', default: 30, min: 5, max: 200, description: 'Branch length' },
      generations: { type: 'number', default: 3, min: 1, max: 6, description: 'Branch depth' },
      color: { type: 'string', default: '#ffffff', description: 'Branch color' },
      brushSize: { type: 'number', default: 4, min: 1, max: 15, description: 'Brush size' },
    },
  },
  {
    name: 'surfaceTrees', description: 'Grow trees out of nearby surfaces using SDF normals', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 200, min: 50, max: 1000, description: 'Search radius' },
      length: { type: 'number', default: 30, min: 5, max: 200, description: 'Branch length' },
      generations: { type: 'number', default: 3, min: 1, max: 6, description: 'Branch depth' },
      color: { type: 'string', default: '#ffffff', description: 'Branch color' },
      brushSize: { type: 'number', default: 4, min: 1, max: 15, description: 'Brush size' },
    },
  },
  {
    name: 'attractorFlow', description: 'Surface-aware flow lines biased by attractors and steered along SDF boundaries', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 300, min: 50, max: 2000, description: 'Search radius' },
      lines: { type: 'number', default: 20, min: 3, max: 80, description: 'Number of flow lines' },
      steps: { type: 'number', default: 40, min: 10, max: 150, description: 'Steps per line' },
      color: { type: 'string', default: '#ffffff', description: 'Line color' },
      brushSize: { type: 'number', default: 3, min: 1, max: 15, description: 'Brush size' },
    },
  },
  {
    name: 'interiorFill', description: 'Detect and fill enclosed regions with hatch/stipple/wash', category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 300, min: 50, max: 2000, description: 'Search radius' },
      style: { type: 'string', default: 'hatch', options: ['hatch', 'stipple', 'wash'], description: 'Fill style' },
      density: { type: 'number', default: 0.5, min: 0.1, max: 1, description: 'Fill density' },
      color: { type: 'string', default: '#ffffff', description: 'Fill color' },
      brushSize: { type: 'number', default: 2, min: 1, max: 10, description: 'Brush size' },
    },
  },
  {
    name: 'vineGrowth',
    description: 'Grow organic branching vines from exterior endpoints with edge-following and color drift',
    category: 'collaborator',
    parameters: {
      nearX: { type: 'number', default: 0, description: 'Center X' },
      nearY: { type: 'number', default: 0, description: 'Center Y' },
      radius: { type: 'number', default: 300, min: 50, max: 2000, description: 'Search radius' },
      maxBranches: { type: 'number', default: 200, min: 5, max: 2000, description: 'Max vine branches' },
      stepLen: { type: 'number', default: 8, min: 3, max: 30, description: 'Growth step length' },
      branchProb: { type: 'number', default: 0.08, min: 0.01, max: 0.3, description: 'Branch probability per step' },
      mode: { type: 'string', default: 'grow', options: ['grow', 'fill'], description: 'Grow outward or fill inward' },
      driftRange: { type: 'number', default: 0.4, min: 0, max: 1, description: 'Color drift intensity (0=none, 1=full)' },
    },
  },
];

// ---------------------------------------------------------------------------
// 1. extend — Continue from an endpoint in its direction
// ---------------------------------------------------------------------------

export function extend(from, endpoint, length, curve, curveTowardX, curveTowardY) {
  const src = findStroke(from, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  endpoint = endpoint || 'end';
  length = length || 100;
  curve = curve || 0;

  const isStart = endpoint === 'start';
  const epIdx = isStart ? 0 : pts.length - 1;
  const ep = pts[epIdx];
  const tang = tangentAt(pts, epIdx);

  // If endpoint is 'start', direction is reversed
  const dir = isStart ? { x: -tang.x, y: -tang.y } : { x: tang.x, y: tang.y };

  const nPts = Math.max(20, Math.round(length / 3));
  const result = [];

  for (let i = 0; i <= nPts; i++) {
    const t = i / nPts;
    let x = ep.x + dir.x * length * t;
    let y = ep.y + dir.y * length * t;

    // Apply curve toward target point using quadratic interpolation
    if (curve > 0 && curveTowardX !== undefined && curveTowardY !== undefined) {
      const ctrlX = ep.x + dir.x * length * 0.5 + (curveTowardX - ep.x) * curve;
      const ctrlY = ep.y + dir.y * length * 0.5 + (curveTowardY - ep.y) * curve;
      const endX = ep.x + dir.x * length;
      const endY = ep.y + dir.y * length;
      // Quadratic bezier: B(t) = (1-t)^2*P0 + 2*(1-t)*t*P1 + t^2*P2
      const mt = 1 - t;
      x = mt * mt * ep.x + 2 * mt * t * ctrlX + t * t * endX;
      y = mt * mt * ep.y + 2 * mt * t * ctrlY + t * t * endY;
    }

    result.push({ x, y });
  }

  return [makeStroke(result, getStrokeColor(src), getStrokeBrushSize(src), getStrokeOpacity(src))];
}

// ---------------------------------------------------------------------------
// 2. branch — Fork from an endpoint at an angle
// ---------------------------------------------------------------------------

export function branch(from, endpoint, angle, length, taper, count) {
  const src = findStroke(from, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  endpoint = endpoint || 'end';
  angle = angle !== undefined ? angle : 45;
  length = length || 80;
  taper = taper !== undefined ? taper : true;
  count = clamp(count || 1, 1, 5);

  const isStart = endpoint === 'start';
  const epIdx = isStart ? 0 : pts.length - 1;
  const ep = pts[epIdx];
  const tang = tangentAt(pts, epIdx);
  const baseDir = isStart ? { x: -tang.x, y: -tang.y } : { x: tang.x, y: tang.y };
  const baseAngle = Math.atan2(baseDir.y, baseDir.x);

  const strokes = [];
  const angleStep = count > 1 ? (angle * 2 * Math.PI / 180) / (count - 1) : 0;
  const startAngle = count > 1 ? baseAngle + (angle * Math.PI / 180) - angleStep * (count - 1) / 2 * 0 : baseAngle + angle * Math.PI / 180;

  for (let b = 0; b < count; b++) {
    let branchAngle;
    if (count === 1) {
      branchAngle = baseAngle + angle * Math.PI / 180;
    } else {
      const spread = angle * Math.PI / 180;
      branchAngle = baseAngle - spread + (2 * spread / (count - 1)) * b;
    }

    const nPts = Math.max(15, Math.round(length / 4));
    const result = [];
    for (let i = 0; i <= nPts; i++) {
      const t = i / nPts;
      result.push({
        x: ep.x + Math.cos(branchAngle) * length * t,
        y: ep.y + Math.sin(branchAngle) * length * t,
      });
    }

    const pressureStyle = taper ? 'taper' : 'default';
    strokes.push(makeStroke(result, getStrokeColor(src), getStrokeBrushSize(src) * 0.8, getStrokeOpacity(src), pressureStyle));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 3. connect — Bridge two nearest unconnected endpoints
// ---------------------------------------------------------------------------

export function connect(nearX, nearY, radius, style, curve) {
  nearX = nearX || 0;
  nearY = nearY || 0;
  radius = radius || 200;
  style = style || 'blend';
  curve = curve !== undefined ? curve : 0.3;

  const nc = _nearbyCache;
  if (!nc || !nc.attachPoints || nc.attachPoints.length < 2) {
    // Fallback: try to use strokes directly
    if (!nc || !nc.strokes || nc.strokes.length < 2) return [];
    // Use first points of two nearest strokes
    const sorted = nc.strokes.slice().sort((a, b) => {
      const pa = (a.path || a.points || [])[0] || { x: 0, y: 0 };
      const pb = (b.path || b.points || [])[0] || { x: 0, y: 0 };
      const da = (pa.x - nearX) ** 2 + (pa.y - nearY) ** 2;
      const db = (pb.x - nearX) ** 2 + (pb.y - nearY) ** 2;
      return da - db;
    });
    const sA = sorted[0], sB = sorted[1];
    const ptsA = getStrokePoints(sA);
    const ptsB = getStrokePoints(sB);
    if (ptsA.length === 0 || ptsB.length === 0) return [];
    const epA = ptsA[ptsA.length - 1];
    const epB = ptsB[0];
    return [_makeBridge(epA, epB, curve, sA, sB, style)];
  }

  // Use attach points from nearby data
  const aps = nc.attachPoints.slice().sort((a, b) => {
    const da = (a.x - nearX) ** 2 + (a.y - nearY) ** 2;
    const db = (b.x - nearX) ** 2 + (b.y - nearY) ** 2;
    return da - db;
  });

  // Pick two from different strokes
  let apA = aps[0];
  let apB = null;
  for (let i = 1; i < aps.length; i++) {
    if (aps[i].strokeId !== apA.strokeId) {
      apB = aps[i];
      break;
    }
  }
  if (!apB && aps.length >= 2) apB = aps[1];
  if (!apA || !apB) return [];

  const sA = findStroke(apA.strokeId, nc);
  const sB = findStroke(apB.strokeId, nc);
  return [_makeBridge(apA, apB, curve, sA, sB, style)];
}

function _makeBridge(epA, epB, curve, sA, sB, style) {
  const dx = epB.x - epA.x;
  const dy = epB.y - epA.y;
  const dist = Math.sqrt(dx * dx + dy * dy);
  const nx = -dy / (dist || 1);
  const ny = dx / (dist || 1);
  const ctrlOff = dist * curve;

  const nPts = Math.max(20, Math.round(dist / 3));
  const result = [];
  for (let i = 0; i <= nPts; i++) {
    const t = i / nPts;
    // Cubic bezier with control points offset perpendicular
    const mt = 1 - t;
    const p1x = epA.x + dx * 0.33 + nx * ctrlOff;
    const p1y = epA.y + dy * 0.33 + ny * ctrlOff;
    const p2x = epA.x + dx * 0.66 - nx * ctrlOff;
    const p2y = epA.y + dy * 0.66 - ny * ctrlOff;
    const x = mt * mt * mt * epA.x + 3 * mt * mt * t * p1x + 3 * mt * t * t * p2x + t * t * t * epB.x;
    const y = mt * mt * mt * epA.y + 3 * mt * mt * t * p1y + 3 * mt * t * t * p2y + t * t * t * epB.y;
    result.push({ x, y });
  }

  let color = '#ffffff';
  let brushSize = 5;
  const colorA = getStrokeColor(sA);
  const colorB = getStrokeColor(sB);
  const sizeA = getStrokeBrushSize(sA);
  const sizeB = getStrokeBrushSize(sB);

  if (style === 'match-a') {
    color = colorA;
    brushSize = sizeA;
  } else if (style === 'match-b') {
    color = colorB;
    brushSize = sizeB;
  } else {
    color = lerpColor(colorA, colorB, 0.5);
    brushSize = (sizeA + sizeB) / 2;
  }

  return makeStroke(result, color, brushSize, Math.min(getStrokeOpacity(sA), getStrokeOpacity(sB)));
}

// ---------------------------------------------------------------------------
// 4. coil — Spiral around a stroke's path
// ---------------------------------------------------------------------------

export function coil(source, loops, radius, taper, direction) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  loops = clamp(Number(loops) || 3, 1, 36);
  radius = clamp(Number(radius) || 20, 2, 160);
  taper = taper !== undefined ? taper : true;
  direction = direction || 'cw';
  const sign = direction === 'ccw' ? -1 : 1;
  const srcColor = getStrokeColor(src);
  const srcSize = getStrokeBrushSize(src);
  const srcOpacity = getStrokeOpacity(src);
  const totalLen = pathLength(pts);
  if (totalLen < 4) return [];

  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;
  for (const p of pts) {
    if (p.x < minX) minX = p.x;
    if (p.y < minY) minY = p.y;
    if (p.x > maxX) maxX = p.x;
    if (p.y > maxY) maxY = p.y;
  }
  const margin = radius * 5 + 24;
  const bounds = {
    minX: minX - margin,
    minY: minY - margin,
    maxX: maxX + margin,
    maxY: maxY + margin,
  };
  let strokeField = buildStrokeField(_nearbyCache, bounds, clamp(radius * 0.55, 5, 20), src.id || null);
  if (strokeField.sampleCount === 0) {
    strokeField = buildStrokeField(_nearbyCache, bounds, clamp(radius * 0.55, 5, 20), null);
  }

  const nSamples = clamp(Math.round(Math.max(120, loops * 52, totalLen / 2.2)), 40, 1200);
  const resampled = resamplePath(pts, nSamples);
  const result = [];
  const jitterAmp = Math.min(radius * 0.18, 8);

  for (let i = 0; i < resampled.length; i++) {
    const t = i / (resampled.length - 1);
    const base = resampled[i];
    const tang = tangentAt(resampled, i);
    const norm = normalAt(resampled, i);
    const phase = t * loops * 2 * Math.PI * sign;
    const r = taper ? radius * (1 - t * 0.55) : radius;

    const steer = strokeField.steerAlongStroke(
      base.x,
      base.y,
      tang,
      Math.max(3, r * 0.85),
      Math.max(r * 4, 24),
      0.95,
      0.8,
    );

    let orbitDir = norm;
    let along = { x: 0, y: 0 };
    let pull = 0;
    if (steer.info) {
      orbitDir = normalizeVec(
        norm.x * 0.42 + steer.info.normal.x * 0.58,
        norm.y * 0.42 + steer.info.normal.y * 0.58,
        norm,
      );
      let tx = steer.info.tangent.x;
      let ty = steer.info.tangent.y;
      if (tx * tang.x + ty * tang.y < 0) {
        tx = -tx;
        ty = -ty;
      }
      along = { x: tx, y: ty };
      pull = steer.error;
    }

    const orbital = Math.sin(phase) * r;
    const breathing = Math.cos(phase * 0.5 + t * 4.2) * r * 0.14;
    const tangentDrift = Math.cos(phase) * r * 0.24;
    const radiusBias = pull * r * 0.28;
    const jitter = (noise2d(base.x * 0.01 + i * 0.13, base.y * 0.01 + i * 0.21) - 0.5)
      * jitterAmp
      * (0.35 + (1 - t) * 0.65);

    result.push({
      x: base.x + orbitDir.x * (orbital + breathing + radiusBias) + along.x * tangentDrift + tang.x * jitter,
      y: base.y + orbitDir.y * (orbital + breathing + radiusBias) + along.y * tangentDrift + tang.y * jitter,
    });
  }

  return [makeStroke(result, srcColor, Math.max(1, srcSize * 0.62), srcOpacity, taper ? 'taper' : 'default')];
}

// ---------------------------------------------------------------------------
// 5. morph — Blend between two strokes
// ---------------------------------------------------------------------------

export function morph(from, to, steps, easing) {
  const srcA = findStroke(from, _nearbyCache);
  const srcB = findStroke(to, _nearbyCache);
  if (!srcA || !srcB) return [];

  steps = steps || 5;
  easing = easing || 'linear';

  const ptsA = getStrokePoints(srcA);
  const ptsB = getStrokePoints(srcB);
  if (ptsA.length < 2 || ptsB.length < 2) return [];

  const n = Math.max(ptsA.length, ptsB.length, 30);
  const rA = resamplePath(ptsA, n);
  const rB = resamplePath(ptsB, n);

  const colorA = getStrokeColor(srcA);
  const colorB = getStrokeColor(srcB);
  const sizeA = getStrokeBrushSize(srcA);
  const sizeB = getStrokeBrushSize(srcB);
  const opA = getStrokeOpacity(srcA);
  const opB = getStrokeOpacity(srcB);

  const strokes = [];
  for (let s = 1; s < steps + 1; s++) {
    const rawT = s / (steps + 1);
    const t = applyEasing(rawT, easing);
    const morphed = [];
    for (let i = 0; i < n; i++) {
      morphed.push({
        x: lerp(rA[i].x, rB[i].x, t),
        y: lerp(rA[i].y, rB[i].y, t),
      });
    }
    strokes.push(makeStroke(
      morphed,
      lerpColor(colorA, colorB, t),
      lerp(sizeA, sizeB, t),
      lerp(opA, opB, t),
    ));
  }
  return strokes;
}

// ---------------------------------------------------------------------------
// 6. hatchGradient — Hatching with density gradient
// ---------------------------------------------------------------------------

export function hatchGradient(x, y, w, h, angle, spacingFrom, spacingTo, gradientDirection, color, brushSize) {
  x = x || 0;
  y = y || 0;
  w = w || 200;
  h = h || 200;
  angle = angle !== undefined ? angle : 45;
  spacingFrom = spacingFrom || 10;
  spacingTo = spacingTo || 30;
  gradientDirection = gradientDirection || 'along';
  color = color || '#ffffff';
  brushSize = brushSize || 3;

  const rad = angle * Math.PI / 180;
  const cos = Math.cos(rad);
  const sin = Math.sin(rad);

  const diagonal = Math.sqrt(w * w + h * h);
  const minX = x, maxX = x + w, minY = y, maxY = y + h;

  // Build stroke boundary segments from nearby cache for clipping
  const strokeSegs = [];
  const nc = _nearbyCache;
  if (nc && nc.strokes) {
    const MARGIN = 6; // proximity threshold — hatch stops this close to a stroke
    for (const s of nc.strokes) {
      const pts = s.path || s.points || [];
      for (let i = 0; i < pts.length - 1; i++) {
        strokeSegs.push({ ax: pts[i].x, ay: pts[i].y, bx: pts[i + 1].x, by: pts[i + 1].y, margin: MARGIN + (s.brushSize || 3) });
      }
    }
  }

  // Test if a point is "too close" to any existing stroke segment
  function nearStroke(px, py) {
    for (const seg of strokeSegs) {
      const dx = seg.bx - seg.ax, dy = seg.by - seg.ay;
      const len2 = dx * dx + dy * dy;
      let t = len2 > 0 ? ((px - seg.ax) * dx + (py - seg.ay) * dy) / len2 : 0;
      t = Math.max(0, Math.min(1, t));
      const cx = seg.ax + t * dx, cy = seg.ay + t * dy;
      const d2 = (px - cx) * (px - cx) + (py - cy) * (py - cy);
      if (d2 < seg.margin * seg.margin) return true;
    }
    return false;
  }

  const strokes = [];
  let d = -diagonal;

  while (d < diagonal) {
    const gT = (d + diagonal) / (2 * diagonal);
    const spacing = lerp(spacingFrom, spacingTo, gradientDirection === 'along' ? gT : (1 - gT));

    const cx = x + w / 2 + (-sin) * d;
    const cy = y + h / 2 + cos * d;
    const lx0 = cx - cos * diagonal;
    const ly0 = cy - sin * diagonal;
    const lx1 = cx + cos * diagonal;
    const ly1 = cy + sin * diagonal;

    const clipped = clipLineToRect({ x: lx0, y: ly0 }, { x: lx1, y: ly1 }, minX, minY, maxX, maxY);
    if (clipped) {
      if (strokeSegs.length > 0) {
        // Walk along the hatch line and emit segments that are in negative space
        const p0 = clipped[0], p1 = clipped[1];
        const hdx = p1.x - p0.x, hdy = p1.y - p0.y;
        const hlen = Math.sqrt(hdx * hdx + hdy * hdy);
        if (hlen < 2) { d += spacing; continue; }
        const steps = Math.max(10, Math.ceil(hlen / 4));
        let segStart = null;
        for (let i = 0; i <= steps; i++) {
          const t = i / steps;
          const px = p0.x + hdx * t, py = p0.y + hdy * t;
          const blocked = nearStroke(px, py);
          if (!blocked) {
            if (!segStart) segStart = { x: px, y: py };
          } else {
            if (segStart) {
              const segEnd = { x: p0.x + hdx * ((i - 1) / steps), y: p0.y + hdy * ((i - 1) / steps) };
              const segLen = Math.sqrt((segEnd.x - segStart.x) ** 2 + (segEnd.y - segStart.y) ** 2);
              if (segLen > 5) {
                strokes.push(makeStroke([segStart, segEnd], color, brushSize, 0.8, 'flat'));
              }
              segStart = null;
            }
          }
        }
        // Emit trailing segment
        if (segStart) {
          const segEnd = p1;
          const segLen = Math.sqrt((segEnd.x - segStart.x) ** 2 + (segEnd.y - segStart.y) ** 2);
          if (segLen > 5) {
            strokes.push(makeStroke([segStart, segEnd], color, brushSize, 0.8, 'flat'));
          }
        }
      } else {
        // No nearby data — fall back to simple rectangle clipping
        strokes.push(makeStroke([clipped[0], clipped[1]], color, brushSize, 0.8, 'flat'));
      }
    }

    d += spacing;
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 7. stitch — Short perpendicular marks along a path
// ---------------------------------------------------------------------------

export function stitch(source, spacing, length, alternating) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  spacing = spacing || 15;
  length = length || 10;
  alternating = alternating !== undefined ? alternating : true;

  const totalLen = pathLength(pts);
  const nStitches = Math.max(1, Math.floor(totalLen / spacing));
  const resampled = resamplePath(pts, nStitches + 1);

  const strokes = [];
  const color = getStrokeColor(src);
  const size = getStrokeBrushSize(src) * 0.6;

  for (let i = 0; i < resampled.length; i++) {
    const norm = normalAt(resampled, i);
    const sign = (alternating && i % 2 === 1) ? -1 : 1;
    const halfLen = length / 2;

    strokes.push(makeStroke([
      { x: resampled[i].x - norm.x * halfLen * sign, y: resampled[i].y - norm.y * halfLen * sign },
      { x: resampled[i].x + norm.x * halfLen * sign, y: resampled[i].y + norm.y * halfLen * sign },
    ], color, size, getStrokeOpacity(src) * 0.8, 'flat'));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 8. bloom — Radiate strokes outward from a point
// ---------------------------------------------------------------------------

export function bloom(atX, atY, count, length, spread, taper, noise, color, brushSize) {
  atX = atX || 0;
  atY = atY || 0;
  count = count || 12;
  length = length || 80;
  spread = spread !== undefined ? spread : 360;
  taper = taper !== undefined ? taper : true;
  noise = noise !== undefined ? noise : 0.2;
  color = color || '#ffffff';
  brushSize = brushSize || 4;

  const spreadRad = spread * Math.PI / 180;
  const startAngle = spread < 360 ? -spreadRad / 2 : 0;
  const step = spreadRad / count;

  const strokes = [];
  for (let i = 0; i < count; i++) {
    const baseAngle = startAngle + step * i + step * 0.5;
    const angleNoise = (noise2d(i * 0.7, 0) * 2 - 1) * noise * 0.5;
    const lenNoise = 1 + (noise2d(0, i * 0.7) * 2 - 1) * noise * 0.3;
    const a = baseAngle + angleNoise;
    const l = length * lenNoise;

    const nPts = Math.max(10, Math.round(l / 5));
    const result = [];
    for (let j = 0; j <= nPts; j++) {
      const t = j / nPts;
      result.push({
        x: atX + Math.cos(a) * l * t,
        y: atY + Math.sin(a) * l * t,
      });
    }

    strokes.push(makeStroke(result, color, brushSize, 0.85, taper ? 'taper' : 'default'));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 9. gradient — Progressive color/offset copies
// ---------------------------------------------------------------------------

export function gradient(source, count, offsetX, offsetY, colorFrom, colorTo, sizeFrom, sizeTo) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  count = count || 5;
  offsetX = offsetX !== undefined ? offsetX : 10;
  offsetY = offsetY !== undefined ? offsetY : 0;
  const srcColor = getStrokeColor(src);
  const srcSize = getStrokeBrushSize(src);
  colorFrom = colorFrom || srcColor;
  colorTo = colorTo || srcColor;
  sizeFrom = sizeFrom !== undefined ? sizeFrom : srcSize;
  sizeTo = sizeTo !== undefined ? sizeTo : srcSize;

  const strokes = [];
  for (let i = 0; i < count; i++) {
    const t = count > 1 ? i / (count - 1) : 0;
    const shifted = offsetPath(pts, offsetX * (i + 1), offsetY * (i + 1));
    strokes.push(makeStroke(
      shifted,
      lerpColor(colorFrom, colorTo, t),
      lerp(sizeFrom, sizeTo, t),
      getStrokeOpacity(src),
    ));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 10. parallel — Offset copies perpendicular to path
// ---------------------------------------------------------------------------

export function parallel(source, count, spacing, colorShift, bothSides) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  count = count || 3;
  spacing = spacing || 10;
  bothSides = bothSides || false;

  const color = colorShift || getStrokeColor(src);
  const size = getStrokeBrushSize(src);
  const opacity = getStrokeOpacity(src);

  const strokes = [];
  const offsets = [];
  for (let i = 1; i <= count; i++) {
    offsets.push(i);
    if (bothSides) offsets.push(-i);
  }

  for (const off of offsets) {
    const result = [];
    for (let i = 0; i < pts.length; i++) {
      const norm = normalAt(pts, i);
      result.push({
        x: pts[i].x + norm.x * spacing * off,
        y: pts[i].y + norm.y * spacing * off,
      });
    }
    strokes.push(makeStroke(result, color, size * 0.9, opacity * 0.85));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 11. echo — Scaled + faded ripple copies
// ---------------------------------------------------------------------------

export function echo(source, count, scaleEach, opacityEach, noise) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  count = count || 3;
  scaleEach = scaleEach !== undefined ? scaleEach : 1.15;
  opacityEach = opacityEach !== undefined ? opacityEach : 0.7;
  noise = noise !== undefined ? noise : 0.1;

  const center = centroid(pts);
  const color = getStrokeColor(src);
  const size = getStrokeBrushSize(src);
  const opacity = getStrokeOpacity(src);

  const strokes = [];
  for (let i = 1; i <= count; i++) {
    const scale = Math.pow(scaleEach, i);
    const op = opacity * Math.pow(opacityEach, i);
    const scaled = scalePath(pts, scale, center);
    const noisy = scaled.map((p, j) => ({
      x: p.x + noise2d(j * 0.3, i * 1.7) * noise * size * 5,
      y: p.y + noise2d(i * 1.7, j * 0.3) * noise * size * 5,
    }));
    strokes.push(makeStroke(noisy, color, size, clamp(op, 0.05, 1)));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 12. cascade — Shrinking rotated copies (fractal fan)
// ---------------------------------------------------------------------------

export function cascade(source, count, scaleEach, rotateEach, anchor) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  count = count || 5;
  scaleEach = scaleEach !== undefined ? scaleEach : 0.75;
  rotateEach = rotateEach !== undefined ? rotateEach : 30;
  anchor = anchor || 'end';

  let anchorPt;
  if (anchor === 'start') anchorPt = pts[0];
  else if (anchor === 'center') anchorPt = centroid(pts);
  else anchorPt = pts[pts.length - 1];

  const color = getStrokeColor(src);
  const size = getStrokeBrushSize(src);
  const opacity = getStrokeOpacity(src);
  const rotRad = rotateEach * Math.PI / 180;

  const strokes = [];
  let current = pts;
  for (let i = 1; i <= count; i++) {
    current = scalePath(current, scaleEach, anchorPt);
    current = rotatePath(current, rotRad, anchorPt);
    strokes.push(makeStroke(
      current.map(p => ({ ...p })),
      color,
      Math.max(3, size * Math.pow(scaleEach, i)),
      clamp(opacity * Math.pow(0.9, i), 0.1, 1),
    ));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 13. mirror — Reflect across an axis
// ---------------------------------------------------------------------------

export function mirror(source, axis, offset, opacity, colorShift) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  axis = axis || 'vertical';
  offset = offset || 0;
  opacity = opacity !== undefined ? opacity : 1;

  const center = centroid(pts);
  const axisPos = (axis === 'vertical' ? center.x : center.y) + offset;
  const mirrored = mirrorPath(pts, axis, axisPos);
  const color = colorShift || getStrokeColor(src);

  return [makeStroke(mirrored, color, getStrokeBrushSize(src), clamp(opacity, 0.05, 1))];
}

// ---------------------------------------------------------------------------
// 14. shadow — Darker, thicker, offset copy
// ---------------------------------------------------------------------------

export function shadow(source, offsetX, offsetY, darken, opacity, blur) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  offsetX = offsetX !== undefined ? offsetX : 5;
  offsetY = offsetY !== undefined ? offsetY : 5;
  darken = darken !== undefined ? darken : 0.4;
  opacity = opacity !== undefined ? opacity : 0.5;
  blur = blur !== undefined ? blur : 0.3;

  const shifted = offsetPath(pts, offsetX, offsetY);
  const color = darkenColor(getStrokeColor(src), darken);
  const size = getStrokeBrushSize(src) * (1 + blur * 0.5);

  return [makeStroke(shifted, color, size, clamp(opacity, 0.05, 1))];
}

// ---------------------------------------------------------------------------
// 15. counterpoint — Inverse shape
// ---------------------------------------------------------------------------

export function counterpoint(source, offsetX, offsetY, amplitude, invertX) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  offsetX = offsetX !== undefined ? offsetX : 0;
  offsetY = offsetY !== undefined ? offsetY : 30;
  amplitude = amplitude !== undefined ? amplitude : 1;
  invertX = invertX || false;

  // Compute chord line from first to last point
  const p0 = pts[0];
  const pN = pts[pts.length - 1];
  const chordDx = pN.x - p0.x;
  const chordDy = pN.y - p0.y;
  const chordLen = Math.sqrt(chordDx * chordDx + chordDy * chordDy);

  const result = [];
  for (let i = 0; i < pts.length; i++) {
    const t = pts.length > 1 ? i / (pts.length - 1) : 0;
    // Point on chord at parameter t
    const chordX = p0.x + chordDx * t;
    const chordY = p0.y + chordDy * t;
    // Deviation from chord
    const devX = pts[i].x - chordX;
    const devY = pts[i].y - chordY;
    // Invert deviations
    result.push({
      x: chordX + (invertX ? -devX : devX) * amplitude + offsetX,
      y: chordY + (-devY) * amplitude + offsetY,
    });
  }

  return [makeStroke(result, getStrokeColor(src), getStrokeBrushSize(src), getStrokeOpacity(src))];
}

// ---------------------------------------------------------------------------
// 16. harmonize — Continue detected pattern
// ---------------------------------------------------------------------------

export function harmonize(nearX, nearY, radius, count, directionX, directionY) {
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 300, 40, 3000);
  count = clamp(Math.round(Number(count) || 3), 1, 16);

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length < 2) return [];

  const entries = [];
  const radius2 = radius * radius;
  for (const s of nc.strokes) {
    const pts = getStrokePoints(s);
    if (pts.length < 2) continue;
    const c = centroid(pts);
    const dx = c.x - nearX;
    const dy = c.y - nearY;
    if (dx * dx + dy * dy > radius2) continue;
    entries.push({ stroke: s, points: pts, centroid: c, projection: 0 });
  }
  if (entries.length < 2) return [];

  const dirXNum = directionX !== undefined && directionX !== null ? Number(directionX) : null;
  const dirYNum = directionY !== undefined && directionY !== null ? Number(directionY) : null;
  const hasDirX = isFinite(dirXNum);
  const hasDirY = isFinite(dirYNum);

  let axis = null;
  if (hasDirX || hasDirY) {
    axis = normalizeVec(hasDirX ? dirXNum : 0, hasDirY ? dirYNum : 0, { x: 1, y: 0 });
  }

  if (!axis || (Math.abs(axis.x) + Math.abs(axis.y) < 1e-8)) {
    // Estimate dominant axis from centroid covariance (PCA in 2D).
    let meanX = 0;
    let meanY = 0;
    for (const e of entries) {
      meanX += e.centroid.x;
      meanY += e.centroid.y;
    }
    meanX /= entries.length;
    meanY /= entries.length;

    let covXX = 0;
    let covXY = 0;
    let covYY = 0;
    for (const e of entries) {
      const dx = e.centroid.x - meanX;
      const dy = e.centroid.y - meanY;
      covXX += dx * dx;
      covXY += dx * dy;
      covYY += dy * dy;
    }
    const theta = 0.5 * Math.atan2(2 * covXY, covXX - covYY);
    axis = normalizeVec(Math.cos(theta), Math.sin(theta), { x: 1, y: 0 });
  }

  for (const e of entries) {
    e.projection = (e.centroid.x - nearX) * axis.x + (e.centroid.y - nearY) * axis.y;
  }
  entries.sort((a, b) => a.projection - b.projection);

  let avgDx = 0;
  let avgDy = 0;
  let avgSpacing = 0;
  let spacingCount = 0;
  for (let i = 1; i < entries.length; i++) {
    const prev = entries[i - 1].centroid;
    const curr = entries[i].centroid;
    avgDx += curr.x - prev.x;
    avgDy += curr.y - prev.y;
    avgSpacing += Math.abs(entries[i].projection - entries[i - 1].projection);
    spacingCount++;
  }
  avgDx /= Math.max(1, entries.length - 1);
  avgDy /= Math.max(1, entries.length - 1);

  if (hasDirX) avgDx = dirXNum;
  if (hasDirY) avgDy = dirYNum;

  let stepMag = Math.sqrt(avgDx * avgDx + avgDy * avgDy);
  if (!isFinite(stepMag) || stepMag < 1e-5) {
    const projStep = spacingCount > 0 ? avgSpacing / spacingCount : clamp(radius * 0.08, 8, 80);
    avgDx = axis.x * projStep;
    avgDy = axis.y * projStep;
    stepMag = projStep;
  }

  const growthDir = normalizeVec(avgDx, avgDy, axis);
  let anchor = entries[entries.length - 1];
  let bestProj = -Infinity;
  for (const e of entries) {
    const p = e.centroid.x * growthDir.x + e.centroid.y * growthDir.y;
    if (p > bestProj) {
      bestProj = p;
      anchor = e;
    }
  }
  if (!anchor || !anchor.points || anchor.points.length < 2) return [];

  const avgColor = getStrokeColor(anchor.stroke);
  const avgSize = getStrokeBrushSize(anchor.stroke);
  const avgOpacity = getStrokeOpacity(anchor.stroke);

  const strokes = [];
  for (let i = 1; i <= count; i++) {
    const shifted = anchor.points.map(p => ({
      x: p.x + avgDx * i,
      y: p.y + avgDy * i,
    }));
    strokes.push(makeStroke(shifted, avgColor, avgSize, avgOpacity));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 17. fragment — Break into scattered segments
// ---------------------------------------------------------------------------

export function fragment(source, pieces, scatter, opacityDecay) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  pieces = pieces || 5;
  scatter = scatter || 30;
  opacityDecay = opacityDecay !== undefined ? opacityDecay : 0.15;

  const segSize = Math.max(2, Math.floor(pts.length / pieces));
  const color = getStrokeColor(src);
  const size = getStrokeBrushSize(src);
  const opacity = getStrokeOpacity(src);

  const strokes = [];
  for (let i = 0; i < pieces; i++) {
    const start = Math.min(i * segSize, pts.length - 1);
    const end = Math.min(start + segSize, pts.length);
    const segment = pts.slice(start, end);
    if (segment.length < 2) continue;

    const dx = (noise2d(i * 1.3, 0.5) * 2 - 1) * scatter;
    const dy = (noise2d(0.5, i * 1.3) * 2 - 1) * scatter;
    const shifted = offsetPath(segment, dx, dy);
    const op = clamp(opacity - opacityDecay * i, 0.05, 1);

    strokes.push(makeStroke(shifted, color, size, op));
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 18. outline — Contour around stroke cluster
// ---------------------------------------------------------------------------

export function outline(strokes, padding, style, color, brushSize) {
  padding = padding !== undefined ? padding : 20;
  style = (style || 'convex').toLowerCase();
  brushSize = brushSize || 3;

  // Parse stroke IDs (comma-separated string)
  const ids = typeof strokes === 'string' ? strokes.split(',').map(s => s.trim()) : (strokes || []);
  const nc = _nearbyCache;
  if (!nc) return [];

  // Collect all points from specified strokes
  const selectedStrokes = [];
  const allPts = [];
  for (const id of ids) {
    const s = findStroke(id, nc);
    if (s) {
      selectedStrokes.push(s);
      for (const p of getStrokePoints(s)) {
        allPts.push(p);
      }
    }
  }

  if (selectedStrokes.length === 0 || allPts.length < 3) return [];

  // Default color from first found stroke
  if (!color) {
    const first = selectedStrokes[0];
    color = first ? getStrokeColor(first) : '#ffffff';
  }

  if (style === 'tight') {
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;
    for (const p of allPts) {
      if (p.x < minX) minX = p.x;
      if (p.y < minY) minY = p.y;
      if (p.x > maxX) maxX = p.x;
      if (p.y > maxY) maxY = p.y;
    }
    const localBounds = {
      minX: minX - padding - 10,
      minY: minY - padding - 10,
      maxX: maxX + padding + 10,
      maxY: maxY + padding + 10,
    };

    let tightPoly = null;
    const faces = extractPlanarFaces(selectedStrokes, localBounds);
    if (faces && faces.length > 0) {
      faces.sort((a, b) => (b.area || 0) - (a.area || 0));
      tightPoly = (faces[0].polygon || []).map((p) => ({ x: p.x, y: p.y }));
    }

    if ((!tightPoly || tightPoly.length < 3) && selectedStrokes.length === 1) {
      const closedPts = getStrokePoints(selectedStrokes[0]);
      if (closedPts.length >= 3) {
        const first = closedPts[0];
        const last = closedPts[closedPts.length - 1];
        const closeD2 = (first.x - last.x) * (first.x - last.x) + (first.y - last.y) * (first.y - last.y);
        if (closeD2 < 24 * 24) {
          tightPoly = closedPts;
        }
      }
    }

    if (tightPoly && tightPoly.length >= 3) {
      const first = tightPoly[0];
      const last = tightPoly[tightPoly.length - 1];
      const closeD2 = (first.x - last.x) * (first.x - last.x) + (first.y - last.y) * (first.y - last.y);
      if (closeD2 < 1e-8) {
        tightPoly = tightPoly.slice(0, -1);
      }
    }

    if (tightPoly && tightPoly.length >= 3) {
      const polyCenter = centroid(tightPoly);
      const bb = shapeBounds(tightPoly);
      const fieldBounds = {
        minX: bb.minX - padding - 6,
        minY: bb.minY - padding - 6,
        maxX: bb.maxX + padding + 6,
        maxY: bb.maxY + padding + 6,
      };
      const shape = {
        polygon: tightPoly,
        centroid: polyCenter,
        area: shoelaceArea(tightPoly),
        strokeIds: [],
        bbox: bb,
      };
      const outlineField = buildSurfaceField(
        [{ points: tightPoly.concat([{ x: tightPoly[0].x, y: tightPoly[0].y }]) }],
        fieldBounds,
        [shape],
        clamp(Math.round(Math.max(bb.maxX - bb.minX, bb.maxY - bb.minY) / 2.2), 80, 150),
      );

      const expanded = tightPoly.map((p) => {
        let n = outlineField.normal(p.x, p.y);
        if (Math.abs(n.x) + Math.abs(n.y) < 1e-8) {
          n = normalizeVec(p.x - polyCenter.x, p.y - polyCenter.y, { x: 1, y: 0 });
        }
        return {
          x: p.x + n.x * padding,
          y: p.y + n.y * padding,
        };
      });
      expanded.push({ ...expanded[0] });
      return [makeStroke(expanded, color, brushSize, 0.8)];
    }
  }

  // Convex fallback
  const hull = convexHull(allPts);
  if (hull.length < 3) return [];
  const center = centroid(hull);
  const expanded = hull.map((p) => {
    const dx = p.x - center.x;
    const dy = p.y - center.y;
    const d = Math.sqrt(dx * dx + dy * dy);
    if (d < 1e-6) return { ...p };
    return {
      x: p.x + (dx / d) * padding,
      y: p.y + (dy / d) * padding,
    };
  });
  expanded.push({ ...expanded[0] });
  return [makeStroke(expanded, color, brushSize, 0.8)];
}

// ---------------------------------------------------------------------------
// 19. contour — Light-aware form-following hatching (THE SHOWCASE)
// ---------------------------------------------------------------------------

export function contour(source, lightAngle, style, layers, intensity) {
  const src = findStroke(source, _nearbyCache);
  if (!src) return [];
  const pts = getStrokePoints(src);
  if (pts.length < 2) return [];

  lightAngle = lightAngle !== undefined ? lightAngle : 315;
  style = (style || 'hatch').toLowerCase();
  if (style !== 'crosshatch') style = 'hatch';
  layers = clamp(layers || 1, 1, 3);
  intensity = intensity !== undefined ? intensity : 0.7;

  const srcColor = getStrokeColor(src);
  const srcSize = getStrokeBrushSize(src);
  const darkColor = darkenColor(srcColor, 0.5);

  // Light direction vector from angle (315 = upper-left, standard math convention)
  const lightRad = lightAngle * Math.PI / 180;
  const lightDir = { x: Math.cos(lightRad), y: Math.sin(lightRad) };

  // Resample source path — ensure enough samples even for short strokes
  const totalLen = pathLength(pts);
  if (totalLen < 5) return []; // truly degenerate (< 5 canvas units)
  const sampleSpacing = Math.min(10, totalLen / 8); // adapt spacing for short strokes
  const nSamples = Math.max(10, Math.floor(totalLen / sampleSpacing));
  const resampled = resamplePath(pts, nSamples);

  const allStrokes = [];

  const layerAngles = style === 'crosshatch'
    ? [0, Math.PI / 2, Math.PI / 4] // crosshatch: orthogonal + diagonal
    : [0, Math.PI / 8, -Math.PI / 8]; // hatch: subtle directional fan

  for (let layer = 0; layer < layers; layer++) {
    const layerAngleOffset = layerAngles[layer] || 0;
    let accumDist = 0;
    let nextHatchDist = 0;

    for (let i = 0; i < resampled.length; i++) {
      // Accumulate distance
      if (i > 0) {
        const dx = resampled[i].x - resampled[i - 1].x;
        const dy = resampled[i].y - resampled[i - 1].y;
        accumDist += Math.sqrt(dx * dx + dy * dy);
      }

      if (accumDist < nextHatchDist) continue;

      // Compute surface normal at this sample point
      const norm = normalAt(resampled, i);

      // Illumination: dot product of normal with light direction
      // Higher = more lit, lower = more shadow
      const illumination = clamp(norm.x * lightDir.x + norm.y * lightDir.y, -1, 1);
      // Remap to 0-1 range (0 = full shadow, 1 = full light)
      const lit = (illumination + 1) / 2;

      // Spacing inversely proportional to shadow: dense in shadow, sparse in light
      const spacingBase = style === 'crosshatch' ? 6 : 5;
      const spacingSpan = style === 'crosshatch' ? 14 : 18;
      const spacing = spacingBase + lit * intensity * spacingSpan;
      nextHatchDist = accumDist + spacing;

      // Skip only the most brightly-lit areas (always produce *some* hatching)
      if (lit > 0.92 && layer === 0) continue;
      if (style === 'crosshatch') {
        if (lit > 0.75 && layer > 0) continue;
      } else if (lit > 0.86 && layer > 0) {
        continue;
      }

      // Hatch line perpendicular to source path (with layer rotation)
      const tang = tangentAt(resampled, i);
      const hatchAngle = Math.atan2(tang.y, tang.x) + Math.PI / 2 + layerAngleOffset;
      const hatchDirX = Math.cos(hatchAngle);
      const hatchDirY = Math.sin(hatchAngle);

      const hatchLen = srcSize * (style === 'crosshatch' ? (2 - layer * 0.18) : (2.15 - layer * 0.08));
      const halfLen = hatchLen / 2;
      const cx = resampled[i].x;
      const cy = resampled[i].y;

      // Generate hatch stroke with pressure variation (thick in shadow, thin in light)
      const hatchPts = [];
      const hatchSegments = 6;
      for (let j = 0; j <= hatchSegments; j++) {
        const ht = j / hatchSegments;
        const pos = -halfLen + ht * hatchLen;
        hatchPts.push({
          x: cx + hatchDirX * pos,
          y: cy + hatchDirY * pos,
          // Pressure: heavier on shadow side
          pressure: clamp(0.3 + (1 - lit) * intensity * 0.6 + (Math.random() - 0.5) * 0.1, 0.1, 1),
        });
      }

      // Color: darker for shadow hatches
      const hatchColor = lerpColor(srcColor, darkColor, (1 - lit) * intensity);
      const hatchSize = Math.max(2, srcSize * 0.4 * (0.5 + (1 - lit) * 0.5));
      const hatchOpacity = clamp(0.4 + (1 - lit) * intensity * 0.5, 0.2, 0.9);

      allStrokes.push(makeStroke(hatchPts, hatchColor, hatchSize, hatchOpacity));
    }
  }

  return allStrokes;
}

// ---------------------------------------------------------------------------
// 20. physarum — Slime mold tube network connecting exterior edges
// ---------------------------------------------------------------------------

export function physarum(nearX, nearY, radius, agents, steps, trailWidth, color) {
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 300, 50, 2000);
  agents = clamp(Math.round(Number(agents) || 30), 5, 100);
  steps = clamp(Math.round(Number(steps) || 50), 10, 220);
  trailWidth = clamp(Number(trailWidth) || 3, 1, 15);
  color = color || '#ffffff';

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];
  if ((!color || color === '#ffffff') && nc.summary?.palette?.length) {
    color = nc.summary.palette[0];
  }

  const bounds = {
    minX: nearX - radius, minY: nearY - radius,
    maxX: nearX + radius, maxY: nearY + radius,
  };
  const shapes = collectSurfaceShapes(nc, bounds, 80);
  const surface = buildSurfaceField(nc.strokes, bounds, shapes, clamp(Math.round(radius / 3), 100, 180));
  const density = buildDensityMap(nc.strokes, bounds, 32);
  const strokeField = buildStrokeField(nc, bounds, clamp(radius / 26, 6, 22));

  const surfaceSeeds = buildSurfaceSeeds(
    nc,
    nearX,
    nearY,
    radius,
    shapes,
    surface,
    Math.min(Math.max(agents, 14), 60),
    { brushScale: 0.85 },
  );
  const endpointAttractors = buildAttractors(nc.strokes, Math.min(agents, 24));
  const attractors = [];
  for (const seed of surfaceSeeds) {
    attractors.push({
      x: seed.x,
      y: seed.y,
      direction: seed.dir,
      strength: clamp(seed.strength, 0, 1),
    });
  }
  for (const a of endpointAttractors) {
    attractors.push({
      x: a.x,
      y: a.y,
      direction: a.direction,
      strength: clamp(a.strength, 0, 1),
    });
  }
  if (attractors.length === 0) return [];

  const pRes = clamp(Math.round(radius / 14), 24, 96);
  const pGrid = new Float32Array(pRes * pRes);
  const pCellW = (bounds.maxX - bounds.minX) / pRes;
  const pCellH = (bounds.maxY - bounds.minY) / pRes;
  const pCell = Math.max(pCellW, pCellH, 1);

  function pIndex(col, row) {
    return row * pRes + col;
  }

  function pSample(x, y) {
    const col = Math.floor((x - bounds.minX) / pCellW);
    const row = Math.floor((y - bounds.minY) / pCellH);
    if (col < 0 || col >= pRes || row < 0 || row >= pRes) return 0;
    return pGrid[pIndex(col, row)];
  }

  function pDeposit(x, y, amount = 1) {
    const col = Math.floor((x - bounds.minX) / pCellW);
    const row = Math.floor((y - bounds.minY) / pCellH);
    for (let dx = -1; dx <= 1; dx++) {
      for (let dy = -1; dy <= 1; dy++) {
        const c = col + dx;
        const r = row + dy;
        if (c < 0 || c >= pRes || r < 0 || r >= pRes) continue;
        const falloff = 1 - Math.min(1, Math.sqrt(dx * dx + dy * dy) / 1.6);
        pGrid[pIndex(c, r)] += amount * falloff;
      }
    }
  }

  function pGradient(x, y) {
    const eps = pCell * 0.75;
    return {
      x: (pSample(x + eps, y) - pSample(x - eps, y)) / Math.max(2 * eps, 1),
      y: (pSample(x, y + eps) - pSample(x, y - eps)) / Math.max(2 * eps, 1),
    };
  }

  // Initialize virtual agents from surface-informed seed points.
  const agentState = [];
  for (let i = 0; i < agents; i++) {
    const seed = surfaceSeeds.length > 0 ? surfaceSeeds[i % surfaceSeeds.length] : null;
    const baseAngle = seed
      ? Math.atan2(seed.dir.y, seed.dir.x)
      : (i / agents) * Math.PI * 2;
    const angle = baseAngle + (noise2d(i * 0.7, 0.3) - 0.5) * 0.8;
    let x = nearX + (noise2d(i * 0.3, 1.7) - 0.5) * radius * 0.3;
    let y = nearY + (noise2d(1.7, i * 0.3) - 0.5) * radius * 0.3;
    if (seed) {
      const spread = radius * 0.06;
      const side = { x: -seed.dir.y, y: seed.dir.x };
      const alongJ = (noise2d(i * 0.31, 2.9) - 0.5) * spread * 0.35;
      const sideJ = (noise2d(2.9, i * 0.31) - 0.5) * spread * 0.5;
      x = seed.x + seed.dir.x * alongJ + side.x * sideJ;
      y = seed.y + seed.dir.y * alongJ + side.y * sideJ;
    }
    agentState.push({
      x,
      y,
      angle,
      trail: [{ x, y }],
    });
  }

  const SENSOR_ANGLE = 25 * Math.PI / 180;
  const SENSOR_DIST = clamp(radius * 0.09, 10, 90);
  const STEP_SIZE = clamp(radius * 0.012, 2.5, 18);
  const TURN_RATE = 0.62;
  const MAX_TRAIL_POINTS = 320;

  // Simulate
  for (let step = 0; step < steps; step++) {
    // Global pheromone evaporation.
    for (let i = 0; i < pGrid.length; i++) {
      pGrid[i] *= 0.986;
    }

    for (const agent of agentState) {
      // Sense at three directions: ahead, left, right
      const pheromone = {
        sample: pSample,
        gradient: pGradient,
      };
      const senseAhead = senseAttractors(
        agent.x, agent.y, agent.angle, SENSOR_DIST, attractors, density, surface, strokeField, pheromone, STEP_SIZE,
      );
      const senseLeft = senseAttractors(
        agent.x, agent.y, agent.angle - SENSOR_ANGLE, SENSOR_DIST, attractors, density, surface, strokeField, pheromone, STEP_SIZE,
      );
      const senseRight = senseAttractors(
        agent.x, agent.y, agent.angle + SENSOR_ANGLE, SENSOR_DIST, attractors, density, surface, strokeField, pheromone, STEP_SIZE,
      );

      // Turn toward strongest signal
      if (senseLeft > senseAhead && senseLeft > senseRight) {
        agent.angle -= SENSOR_ANGLE * TURN_RATE;
      } else if (senseRight > senseAhead && senseRight > senseLeft) {
        agent.angle += SENSOR_ANGLE * TURN_RATE;
      }

      const heading = { x: Math.cos(agent.angle), y: Math.sin(agent.angle) };
      const strokeSteer = strokeField.steerAlongStroke(
        agent.x,
        agent.y,
        heading,
        clamp(STEP_SIZE * 3.2, 8, 44),
        clamp(STEP_SIZE * 11, 26, 150),
        0.95,
        0.85,
      );
      if (strokeSteer.info) {
        const nearFactor = clamp(1 - strokeSteer.info.dist / (STEP_SIZE * 9), 0, 1);
        const blend = clamp(0.22 + nearFactor * 0.53, 0.2, 0.78);
        const steerDir = normalizeVec(strokeSteer.x, strokeSteer.y, heading);
        const steered = normalizeVec(
          heading.x * (1 - blend) + steerDir.x * blend,
          heading.y * (1 - blend) + steerDir.y * blend,
          heading,
        );
        agent.angle = Math.atan2(steered.y, steered.x);
      }

      // Add small random jitter
      agent.angle += (noise2d(step * 0.11, agent.x * 0.009 + agent.y * 0.007) - 0.5) * 0.22;

      // Move
      let nx = agent.x + Math.cos(agent.angle) * STEP_SIZE;
      let ny = agent.y + Math.sin(agent.angle) * STEP_SIZE;

      // Clamp to radius
      const dx = nx - nearX;
      const dy = ny - nearY;
      const d = Math.sqrt(dx * dx + dy * dy);
      if (d > radius * 1.5) {
        nx = nearX + (dx / d) * radius * 1.5;
        ny = nearY + (dy / d) * radius * 1.5;
        agent.angle += Math.PI * 0.58;
      }

      const sd1 = surface.signedDistance(nx, ny);
      if (isFinite(sd1) && sd1 < -STEP_SIZE * 0.7) {
        const normal = surface.normal(nx, ny);
        nx += normal.x * STEP_SIZE * 1.05;
        ny += normal.y * STEP_SIZE * 1.05;
        agent.angle += Math.PI * 0.62;
      }

      const localDensity = density.get(nx, ny);
      if (localDensity > 0.72) {
        agent.angle += (noise2d(nx * 0.012, ny * 0.012 + step * 0.09) - 0.5) * 0.45;
      }

      agent.x = nx;
      agent.y = ny;
      agent.trail.push({ x: nx, y: ny });
      if (agent.trail.length > MAX_TRAIL_POINTS) {
        agent.trail.shift();
      }
      pDeposit(nx, ny, 0.34 + localDensity * 0.22);
    }
  }

  // Convert trails to strokes, splitting into segments
  const strokes = [];
  for (const agent of agentState) {
    if (agent.trail.length < 3) continue;
    // Split trail into reasonable segments
    const maxSegLen = 42;
    for (let i = 0; i < agent.trail.length; i += maxSegLen) {
      const seg = agent.trail.slice(i, Math.min(i + maxSegLen + 1, agent.trail.length));
      if (seg.length >= 2) {
        const t = i / agent.trail.length;
        const mid = seg[Math.floor(seg.length / 2)];
        const styled = strokeField.styleAt(mid.x, mid.y, color, trailWidth, 0.8, STEP_SIZE * 14);
        const segColor = color && color !== '#ffffff' ? color : styled.color;
        const segWidth = clamp((trailWidth * 0.7 + styled.brushSize * 0.3), 1, 20);
        const opacity = clamp((0.34 + t * 0.44) * (0.75 + styled.opacity * 0.25), 0.2, 0.9);
        strokes.push(makeStroke(seg, segColor, segWidth, opacity, 'taper'));
      }
    }
  }

  return strokes;
}

function senseAttractors(x, y, angle, dist, attractors, density, surface, strokeField, pheromone, stepSize = 4) {
  const sx = x + Math.cos(angle) * dist;
  const sy = y + Math.sin(angle) * dist;
  let signal = 0;
  const heading = { x: Math.cos(angle), y: Math.sin(angle) };

  // Attractor pull
  for (const a of attractors) {
    const dx = a.x - sx;
    const dy = a.y - sy;
    const d2 = dx * dx + dy * dy;
    if (d2 < 1) continue;
    const d = Math.sqrt(d2);
    const align = (dx / d) * heading.x + (dy / d) * heading.y;
    signal += a.strength * (1 + align * 0.35) / (1 + d2 * 0.001);
  }

  // Avoid dense areas
  const d = density.get(sx, sy);
  signal -= d * 1.35;

  if (surface) {
    const sd = surface.signedDistance(sx, sy);
    const ud = Math.abs(sd);
    if (isFinite(ud)) {
      const n = surface.normal(sx, sy);
      const tangent = { x: -n.y, y: n.x };
      const nearW = clamp(1 - ud / Math.max(stepSize * 10, 1), 0, 1);
      const tangentAlign = Math.abs(heading.x * tangent.x + heading.y * tangent.y);
      signal += nearW * (0.35 + tangentAlign * 0.7);
      if (sd < 0) {
        signal -= clamp((-sd) / Math.max(stepSize * 2.2, 1), 0, 1) * 1.55;
      }
    }
  }

  if (strokeField) {
    const steer = strokeField.steerAlongStroke(
      sx,
      sy,
      heading,
      clamp(stepSize * 3, 8, 42),
      clamp(stepSize * 12, 24, 150),
      1,
      0.8,
    );
    if (steer.info) {
      const align = heading.x * steer.info.tangent.x + heading.y * steer.info.tangent.y;
      const follow = Math.max(0, align);
      signal += follow * 0.85;
      signal += clamp(1 - steer.info.dist / Math.max(stepSize * 10, 1), 0, 1) * 0.55;
    }
  }

  if (pheromone) {
    const p = pheromone.sample(sx, sy);
    signal += clamp(p, 0, 2.2) * 0.7;
    const g = pheromone.gradient(sx, sy);
    signal += (g.x * heading.x + g.y * heading.y) * 0.28;
  }

  return signal;
}

// ---------------------------------------------------------------------------
// 21. attractorBranch — Grow fractal branches from exterior edge points
// ---------------------------------------------------------------------------

export function attractorBranch(nearX, nearY, radius, length, generations, color, brushSize) {
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 200, 50, 1500);
  length = clamp(Number(length) || 30, 5, 220);
  generations = clamp(Math.round(Number(generations) || 3), 1, 7);
  const colorOverride = color && color !== '#ffffff' ? color : null;
  const brushOverride = brushSize !== undefined ? clamp(Number(brushSize) || 4, 1, 24) : null;

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  const bounds = {
    minX: nearX - radius,
    minY: nearY - radius,
    maxX: nearX + radius,
    maxY: nearY + radius,
  };
  const shapes = collectSurfaceShapes(nc, bounds, 80);
  const surface = buildSurfaceField(nc.strokes, bounds, shapes, clamp(Math.round(radius / 3), 100, 180));
  const density = buildDensityMap(nc.strokes, bounds, 36);
  const strokeField = buildStrokeField(nc, bounds, clamp(radius / 30, 6, 22));
  const seeds = buildSurfaceSeeds(
    nc,
    nearX,
    nearY,
    radius,
    shapes,
    surface,
    Math.min(42, Math.max(12, Math.round(radius / 38))),
    {
      colorOverride,
      brushOverride,
      brushScale: 0.82,
    },
  );
  if (seeds.length === 0) return [];

  const strokes = [];
  const MAX_TREE_STROKES = 320;
  const BASE_BRANCH_ANGLE = 30 * Math.PI / 180;
  const LENGTH_SHRINK = 0.74;

  function growBranch(seed, x, y, dir, len, gen, size) {
    if (gen <= 0 || len < 4 || size < 0.6 || strokes.length >= MAX_TREE_STROKES) return;

    const step = Math.max(2.3, len / Math.max(6, Math.round(len / 2.8)));
    const steps = Math.max(6, Math.round(len / step));
    const pts = [{ x, y }];

    let px = x;
    let py = y;
    let dx = dir.x;
    let dy = dir.y;

    for (let i = 0; i < steps; i++) {
      const unsignedDist = surface.unsignedDistance(px, py);
      const signedDist = surface.signedDistance(px, py);
      if (!isFinite(unsignedDist)) break;

      const normal = surface.normal(px, py);
      const tangent = { x: -normal.y, y: normal.x };
      const nearSurface = clamp(1 - unsignedDist / (step * 8), 0, 1);
      const insidePush = signedDist < 0 ? clamp((-signedDist) / (step * 3), 0, 1) : 0;

      const strokeSteer = strokeField.steerAlongStroke(
        px,
        py,
        { x: dx, y: dy },
        clamp(len * 0.24, 6, 38),
        clamp(len * 2.8, 24, 150),
        1.0,
        0.92,
      );
      if (strokeSteer.info) {
        const strokeNear = clamp(1 - strokeSteer.info.dist / Math.max(len * 0.75, step * 12), 0, 1);
        dx += strokeSteer.x * (0.26 + strokeNear * 0.68);
        dy += strokeSteer.y * (0.26 + strokeNear * 0.68);
      }

      dx += normal.x * (0.24 + nearSurface * 0.42 + insidePush * 1.05);
      dy += normal.y * (0.24 + nearSurface * 0.42 + insidePush * 1.05);

      const tangentNoise = (noise2d(px * 0.006 + gen * 0.7, py * 0.006 + i * 0.3) - 0.5) * 0.55;
      dx += tangent.x * tangentNoise * nearSurface;
      dy += tangent.y * tangentNoise * nearSurface;

      const localDensity = density.get(px, py);
      if (localDensity > 0.55) {
        dx += (noise2d(py * 0.014, i * 0.31) - 0.5) * localDensity * 0.75;
        dy += (noise2d(i * 0.31, px * 0.014) - 0.5) * localDensity * 0.75;
      }

      dx += (noise2d(gen * 1.3 + i * 0.17, px * 0.008) - 0.5) * 0.35;
      dy += (noise2d(py * 0.008, gen * 1.3 + i * 0.17) - 0.5) * 0.35;

      const dirLen = Math.sqrt(dx * dx + dy * dy);
      if (dirLen < 1e-6) break;
      dx /= dirLen;
      dy /= dirLen;

      px += dx * step;
      py += dy * step;

      const offX = px - nearX;
      const offY = py - nearY;
      if (offX * offX + offY * offY > (radius * 3.1) * (radius * 3.1)) break;

      if (i > 2 && surface.unsignedDistance(px, py) < step * 0.22) {
        px += tangent.x * step * 0.34;
        py += tangent.y * step * 0.34;
      }
      pts.push({ x: px, y: py });
    }

    if (pts.length < 3) return;
    const opacity = clamp(0.92 - (generations - gen) * 0.14, 0.28, 0.92);
    strokes.push(makeStroke(pts, seed.color, size, opacity, 'taper'));

    if (gen <= 1) return;

    const tail = pts[pts.length - 1];
    const prev = pts[pts.length - 2];
    const outDir = normalizeVec(tail.x - prev.x, tail.y - prev.y, dir);
    const baseAngle = Math.atan2(outDir.y, outDir.x);

    const spreadJitter = (noise2d(tail.x * 0.007, tail.y * 0.007 + gen) - 0.5) * (8 * Math.PI / 180);
    const spread = BASE_BRANCH_ANGLE + spreadJitter;
    const childLen = len * LENGTH_SHRINK;
    const childSize = Math.max(1, size * 0.76);

    const childAngles = [baseAngle - spread, baseAngle + spread];
    const localField = strokeField.influence(tail.x, tail.y, outDir, clamp(childLen * 3.2, 20, 150));
    if (localField) {
      const tangentAngle = Math.atan2(localField.tangent.y, localField.tangent.x);
      childAngles.push(tangentAngle + spread * 0.42);
    }
    if (gen >= 4 && noise2d(gen * 2.1, tail.x * 0.013 + tail.y * 0.009) > 0.65) {
      childAngles.push(baseAngle + spreadJitter * 0.4);
    }

    for (const a of childAngles) {
      if (strokes.length >= MAX_TREE_STROKES) break;
      growBranch(
        seed,
        tail.x,
        tail.y,
        { x: Math.cos(a), y: Math.sin(a) },
        childLen,
        gen - 1,
        childSize,
      );
    }
  }

  for (const seed of seeds) {
    if (strokes.length >= MAX_TREE_STROKES) break;
    const styled = strokeField.styleAt(seed.x, seed.y, seed.color, seed.brushSize, 0.9, radius * 0.25);
    const trunkSeed = {
      ...seed,
      color: colorOverride ? seed.color : styled.color,
      brushSize: brushOverride ? seed.brushSize : clamp(seed.brushSize * 0.55 + styled.brushSize * 0.45, 1, 24),
    };
    const trunkLen = length * (0.86 + seed.strength * 0.62);
    const trunkSize = trunkSeed.brushSize * (0.92 + seed.strength * 0.24);
    growBranch(trunkSeed, trunkSeed.x, trunkSeed.y, trunkSeed.dir, trunkLen, generations, trunkSize);
  }

  return strokes.slice(0, MAX_TREE_STROKES);
}

// Alias for discoverability in agent prompts ("grow trees out of everything").
export function surfaceTrees(nearX, nearY, radius, length, generations, color, brushSize) {
  return attractorBranch(nearX, nearY, radius, length, generations, color, brushSize);
}

// ---------------------------------------------------------------------------
// 22. attractorFlow — Flow field biased toward exterior attractors
// ---------------------------------------------------------------------------

export function attractorFlow(nearX, nearY, radius, lines, steps, color, brushSize) {
  const colorOverride = color;
  const sizeOverride = brushSize;
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 300, 50, 2000);
  lines = clamp(Math.round(Number(lines) || 20), 3, 80);
  steps = clamp(Math.round(Number(steps) || 40), 10, 180);
  color = color || '#ffffff';
  brushSize = clamp(Number(brushSize) || 3, 1, 15);

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  const bounds = {
    minX: nearX - radius, minY: nearY - radius,
    maxX: nearX + radius, maxY: nearY + radius,
  };
  const shapes = collectSurfaceShapes(nc, bounds, 80);
  const surface = buildSurfaceField(nc.strokes, bounds, shapes, clamp(Math.round(radius / 3), 100, 185));
  const density = buildDensityMap(nc.strokes, bounds, 32);
  const strokeField = buildStrokeField(nc, bounds, clamp(radius / 28, 6, 22));
  const seeds = buildSurfaceSeeds(
    nc,
    nearX,
    nearY,
    radius,
    shapes,
    surface,
    Math.min(lines * 2, 90),
    { brushScale: 0.78 },
  );
  const endpointAttractors = buildAttractors(nc.strokes, 20);
  const attractors = [];
  for (const seed of seeds) {
    attractors.push({
      x: seed.x,
      y: seed.y,
      direction: seed.dir,
      strength: clamp(seed.strength, 0, 1),
    });
  }
  for (const a of endpointAttractors) {
    attractors.push({
      x: a.x,
      y: a.y,
      direction: a.direction,
      strength: clamp(a.strength, 0, 1),
    });
  }
  if (attractors.length === 0) return [];

  const stepSize = clamp(radius * 0.017, 2.4, 24);
  const strokes = [];

  for (let i = 0; i < lines; i++) {
    const seed = seeds.length > 0 ? seeds[i % seeds.length] : null;
    let x;
    let y;
    let heading;

    if (seed) {
      const startJitter = (noise2d(i * 0.73, 0.41) - 0.5) * stepSize;
      x = seed.x + seed.dir.x * (2 + startJitter);
      y = seed.y + seed.dir.y * (2 + startJitter);
      heading = { x: seed.dir.x, y: seed.dir.y };
    } else {
      // Fallback: ring around center
      const startAngle = (i / lines) * Math.PI * 2;
      const startR = radius * (0.2 + noise2d(i * 0.7, 0.5) * 0.3);
      x = nearX + Math.cos(startAngle) * startR;
      y = nearY + Math.sin(startAngle) * startR;
      heading = { x: Math.cos(startAngle), y: Math.sin(startAngle) };
    }

    const pts = [{ x, y }];
    const localVisit = new Map();
    const lineColor = colorOverride && colorOverride !== '#ffffff'
      ? color
      : (seed ? seed.color : color);
    const lineSize = sizeOverride !== undefined && sizeOverride !== null
      ? brushSize
      : clamp(seed ? seed.brushSize * 0.85 : brushSize, 1, 20);

    for (let s = 0; s < steps; s++) {
      // Compute flow direction: attractor pull + previous heading + density/surface steering.
      let fx = heading.x * 0.72;
      let fy = heading.y * 0.72;

      for (const a of attractors) {
        const adx = a.x - x;
        const ady = a.y - y;
        const d2 = adx * adx + ady * ady;
        if (d2 < 1) continue;
        const d = Math.sqrt(d2);
        fx += (adx / d) * a.strength / (1 + d * 0.012);
        fy += (ady / d) * a.strength / (1 + d * 0.012);
      }

      // Repel from dense areas using density gradient approximation.
      const gx = density.get(x + 5, y) - density.get(x - 5, y);
      const gy = density.get(x, y + 5) - density.get(x, y - 5);
      fx -= gx * 3;
      fy -= gy * 3;

      // Surface-aware steering: follow tangents near boundaries; push outward if inside.
      const signedDist = surface.signedDistance(x, y);
      const unsignedDist = Math.abs(signedDist);
      if (isFinite(unsignedDist)) {
        const normal = surface.normal(x, y);
        const tangent = { x: -normal.y, y: normal.x };
        const nearW = clamp(1 - unsignedDist / (stepSize * 8), 0, 1);
        if (nearW > 0) {
          const dot = fx * tangent.x + fy * tangent.y;
          const signT = dot >= 0 ? 1 : -1;
          const tangentBlend = nearW * 0.72;
          fx = fx * (1 - tangentBlend) + tangent.x * signT * tangentBlend;
          fy = fy * (1 - tangentBlend) + tangent.y * signT * tangentBlend;
        }
        if (signedDist < 0) {
          const insidePush = clamp((-signedDist) / (stepSize * 2.5), 0, 1);
          fx += normal.x * insidePush * 1.25;
          fy += normal.y * insidePush * 1.25;
        }
      }

      const strokeSteer = strokeField.steerAlongStroke(
        x,
        y,
        heading,
        clamp(stepSize * 3.4, 8, 46),
        clamp(stepSize * 12, 24, 170),
        1.05,
        0.95,
      );
      if (strokeSteer.info) {
        const strokeNear = clamp(1 - strokeSteer.info.dist / (stepSize * 11), 0, 1);
        fx += strokeSteer.x * (0.34 + strokeNear * 0.92);
        fy += strokeSteer.y * (0.34 + strokeNear * 0.92);
      }

      // Add curl noise for organic feel
      const noiseAngle = noise2d(x * 0.005, y * 0.005) * Math.PI * 2;
      fx += Math.cos(noiseAngle) * 0.3;
      fy += Math.sin(noiseAngle) * 0.3;

      // Normalize and step
      const fLen = Math.sqrt(fx * fx + fy * fy);
      if (fLen < 1e-6) break;
      heading = { x: fx / fLen, y: fy / fLen };
      x += heading.x * stepSize;
      y += heading.y * stepSize;

      const visitScale = Math.max(4, stepSize * 1.8);
      const vk = `${Math.floor(x / visitScale)},${Math.floor(y / visitScale)}`;
      const v = (localVisit.get(vk) || 0) + 1;
      localVisit.set(vk, v);
      if (v > 3 && s > 8) break;

      // Check bounds
      const dx = x - nearX;
      const dy = y - nearY;
      if (dx * dx + dy * dy > radius * radius * 2.1) break;
      if (surface.signedDistance(x, y) < -stepSize * 0.8) {
        const n = surface.normal(x, y);
        x += n.x * stepSize * 0.95;
        y += n.y * stepSize * 0.95;
      }

      pts.push({ x, y });
    }

    if (pts.length >= 3) {
      const seedStrength = seed ? clamp(seed.strength, 0, 1) : 0.5;
      const mid = pts[Math.floor(pts.length / 2)];
      const styled = strokeField.styleAt(mid.x, mid.y, lineColor, lineSize, 0.85, stepSize * 15);
      const drawColor = colorOverride && colorOverride !== '#ffffff'
        ? lineColor
        : lerpColor(lineColor, styled.color, 0.45);
      const drawSize = sizeOverride !== undefined && sizeOverride !== null
        ? lineSize
        : clamp(lineSize * 0.65 + styled.brushSize * 0.35, 1, 20);
      const opacity = clamp((0.34 + seedStrength * 0.42 + noise2d(i * 1.3, 0.7) * 0.16) * (0.78 + styled.opacity * 0.24), 0.22, 0.92);
      strokes.push(makeStroke(pts, drawColor, drawSize, opacity, 'taper'));
    }
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 23. interiorFill — Fill enclosed regions with hatch/stipple/wash
// ---------------------------------------------------------------------------

export function interiorFill(nearX, nearY, radius, style, density, color, brushSize) {
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 300, 50, 2000);
  style = (style || 'hatch').toLowerCase();
  if (!['hatch', 'stipple', 'wash'].includes(style)) style = 'hatch';
  density = density !== undefined ? clamp(density, 0.1, 1) : 0.5;
  brushSize = clamp(Number(brushSize) || 2, 1, 12);

  // Default color: prefer dominant palette color from nearby data, fall back to white
  if (!color || color === '#ffffff') {
    const nc0 = _nearbyCache;
    if (nc0 && nc0.summary && nc0.summary.palette && nc0.summary.palette.length > 0) {
      color = nc0.summary.palette[0];
    } else {
      color = color || '#ffffff';
    }
  }

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  const bounds = {
    minX: nearX - radius, minY: nearY - radius,
    maxX: nearX + radius, maxY: nearY + radius,
  };

  // Try topology-aware shape detection first (uses relay topology block)
  let shapes = [];
  if (nc.topology) {
    shapes = detectClosedShapes(nc);
  }

  // PSLG face extraction — finds ALL enclosed regions including self-intersections
  if (shapes.length === 0) {
    shapes = extractPlanarFaces(nc.strokes, bounds);
    // Carry stroke color through for single-stroke faces
    for (const face of shapes) {
      if (face.strokeIds.length === 1) {
        const src = nc.strokes.find(s => s.id === face.strokeIds[0]);
        if (src) face.color = src.color || (src.brush && src.brush.color);
      }
    }
  }

  if (shapes.length === 0) return [];

  const strokes = [];

  for (const shape of shapes) {
    const polygon = shape.polygon;
    if (!polygon || polygon.length < 3) continue;

    // Per-shape color: prefer shape's own color (from self-closing detection), then global color
    const shapeColor = shape.color || color;

    const cx = shape.centroid.x;
    const cy = shape.centroid.y;

    // Compute bounding box of the polygon
    let pMinX = Infinity, pMaxX = -Infinity, pMinY = Infinity, pMaxY = -Infinity;
    for (const p of polygon) {
      if (p.x < pMinX) pMinX = p.x;
      if (p.x > pMaxX) pMaxX = p.x;
      if (p.y < pMinY) pMinY = p.y;
      if (p.y > pMaxY) pMaxY = p.y;
    }
    const polyW = pMaxX - pMinX;
    const polyH = pMaxY - pMinY;
    const diagonal = Math.sqrt(polyW * polyW + polyH * polyH);
    let cleanPoly = polygon.map((p) => ({ x: p.x, y: p.y }));
    if (cleanPoly.length >= 2) {
      const first = cleanPoly[0];
      const last = cleanPoly[cleanPoly.length - 1];
      const d2 = (first.x - last.x) * (first.x - last.x) + (first.y - last.y) * (first.y - last.y);
      if (d2 < 1e-8) cleanPoly = cleanPoly.slice(0, -1);
    }
    if (cleanPoly.length < 3) continue;
    const shapeBBox = shapeBounds(cleanPoly);
    const localShape = {
      polygon: cleanPoly,
      centroid: { x: cx, y: cy },
      area: shape.area || shoelaceArea(cleanPoly),
      strokeIds: shape.strokeIds || [],
      bbox: shapeBBox,
    };
    const fieldPad = Math.max(8, brushSize * 4);
    const localBounds = {
      minX: shapeBBox.minX - fieldPad,
      minY: shapeBBox.minY - fieldPad,
      maxX: shapeBBox.maxX + fieldPad,
      maxY: shapeBBox.maxY + fieldPad,
    };
    const shapeField = buildSurfaceField(
      [{ points: cleanPoly.concat([{ x: cleanPoly[0].x, y: cleanPoly[0].y }]) }],
      localBounds,
      [localShape],
      clamp(Math.round(Math.max(polyW, polyH) / 2.2), 80, 150),
    );
    const edgeWeight = (x, y, falloff) => {
      const d = shapeField.unsignedDistance(x, y);
      if (!isFinite(d)) return 0;
      return clamp(d / Math.max(falloff, 1), 0, 1);
    };

    if (style === 'hatch') {
      // Generate hatch lines with gradient and edge-distance weighting.
      const spacing = lerp(15, 4, density);
      const angle = 45 * Math.PI / 180;
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);
      const darkShade = darkenColor(shapeColor, 0.4);
      const totalSteps = Math.max(1, Math.ceil(2 * diagonal / spacing));
      let stepIndex = 0;

      for (let d = -diagonal; d < diagonal; d += spacing) {
        const t = stepIndex / totalSteps; // 0 → 1 across the shape
        const hatchColor = lerpColor(shapeColor, darkShade, t);
        const hatchOpacity = lerp(0.7, 0.4, t);

        const lx0 = cx + (-sin) * d - cos * diagonal;
        const ly0 = cy + cos * d - sin * diagonal;
        const lx1 = cx + (-sin) * d + cos * diagonal;
        const ly1 = cy + cos * d + sin * diagonal;

        // Clip hatch line to polygon boundary
        const segments = clipLineToPolygon({ x: lx0, y: ly0 }, { x: lx1, y: ly1 }, polygon);
        for (const [segStart, segEnd] of segments) {
          const segLen = Math.sqrt((segEnd.x - segStart.x) ** 2 + (segEnd.y - segStart.y) ** 2);
          if (segLen > 3) {
            const mid = {
              x: (segStart.x + segEnd.x) * 0.5,
              y: (segStart.y + segEnd.y) * 0.5,
            };
            const ew = edgeWeight(mid.x, mid.y, spacing * 2.4);
            const weightedOpacity = hatchOpacity * (0.35 + ew * 0.65);
            const weightedSize = brushSize * (0.75 + ew * 0.45);
            strokes.push(makeStroke(
              [segStart, segEnd],
              hatchColor, weightedSize, weightedOpacity, 'flat'
            ));
          }
        }
        stepIndex++;
      }
    } else if (style === 'stipple') {
      // Generate random dots, weighted by SDF distance from boundary.
      const dotCount = Math.round((shape.area || polyW * polyH) * density * 0.012);
      for (let i = 0; i < dotCount; i++) {
        const px = pMinX + noise2d(i * 0.7, cx * 0.01) * polyW;
        const py = pMinY + noise2d(cx * 0.01, i * 0.7) * polyH;
        if (!pointInPolygon(px, py, polygon)) continue;
        const ew = edgeWeight(px, py, Math.max(brushSize * 5, 10));
        const acceptance = 0.2 + ew * 0.8;
        const pick = noise2d(px * 0.03 + i * 0.11, py * 0.03 + i * 0.17);
        if (pick > acceptance) continue;
        const dotSize = brushSize * (0.6 + ew * 0.6);
        const dotOpacity = clamp(0.15 + ew * 0.55 + noise2d(i * 0.3, 0.5) * 0.18, 0.12, 0.85);
        const dotDelta = Math.max(0.6, dotSize * 0.35);
        strokes.push(makeStroke(
          [{ x: px, y: py }, { x: px + dotDelta, y: py + dotDelta }],
          shapeColor, dotSize, dotOpacity
        ));
      }
    } else if (style === 'wash') {
      // Generate overlapping soft strokes with edge falloff.
      const regionRadius = Math.max(polyW, polyH) / 2;
      const washCount = Math.round(5 + density * 10);
      for (let i = 0; i < washCount; i++) {
        const angle = noise2d(i * 0.5, cy * 0.01) * Math.PI * 2;
        const len = regionRadius * (0.5 + noise2d(i * 0.3, 0.7) * 0.5);
        const startX = cx + (noise2d(i * 0.7, 1.3) - 0.5) * regionRadius * 0.5;
        const startY = cy + (noise2d(1.3, i * 0.7) - 0.5) * regionRadius * 0.5;

        // Only start inside the polygon
        if (!pointInPolygon(startX, startY, polygon)) continue;
        if (edgeWeight(startX, startY, regionRadius * 0.5) < 0.06) continue;

        const pts = [];
        const nPts = 15;
        let edgeAccum = 0;
        for (let j = 0; j <= nPts; j++) {
          const t = j / nPts;
          const wx = startX + Math.cos(angle) * len * t + (noise2d(j * 0.3, i * 0.7) - 0.5) * 10;
          const wy = startY + Math.sin(angle) * len * t + (noise2d(i * 0.7, j * 0.3) - 0.5) * 10;
          // Stop if waypoint exits the polygon
          if (!pointInPolygon(wx, wy, polygon)) break;
          const ew = edgeWeight(wx, wy, regionRadius * 0.5);
          if (ew < 0.03) break;
          edgeAccum += ew;
          pts.push({ x: wx, y: wy });
        }

        if (pts.length >= 2) {
          const avgEdge = edgeAccum / pts.length;
          const washOpacity = clamp((0.09 + density * 0.2) * (0.45 + avgEdge * 0.8), 0.06, 0.45);
          const washSize = brushSize * (1.9 + avgEdge * 1.5);
          strokes.push(makeStroke(pts, shapeColor, washSize, washOpacity));
        }
      }
    }
  }

  return strokes;
}

// ---------------------------------------------------------------------------
// 24. vineGrowth — Organic branching vines from exterior endpoints
// ---------------------------------------------------------------------------

/**
 * Grow organic vine-like branches from exterior endpoints of existing strokes.
 *
 * Combines SDF edge-following, stochastic branching, HSL color drift from
 * source stroke, and self-avoidance to produce organic growth patterns.
 *
 * @param {number} nearX - Center X of search area
 * @param {number} nearY - Center Y of search area
 * @param {number} radius - Search radius
 * @param {number} maxBranches - Maximum total vine branches
 * @param {number} stepLen - Step distance per growth tick
 * @param {number} branchProb - Base branching probability per step
 * @param {string} mode - 'grow' (outward from endpoints) or 'fill' (inward from face boundaries)
 * @param {number} driftRange - Color drift intensity (0=none, 1=wild)
 * @returns {Array} Array of stroke objects
 */
export function vineGrowth(nearX, nearY, radius, maxBranches, stepLen, branchProb, mode, driftRange) {
  nearX = Number(nearX) || 0;
  nearY = Number(nearY) || 0;
  radius = clamp(Number(radius) || 300, 50, 2000);
  maxBranches = clamp(Math.round(Number(maxBranches) || 200), 5, 2200);
  stepLen = clamp(Number(stepLen) || 8, 3, 30);
  branchProb = clamp(Number(branchProb) || 0.08, 0.01, 0.3);
  mode = mode || 'grow';
  driftRange = driftRange !== undefined ? clamp(Number(driftRange), 0, 1) : 0.4;

  const nc = _nearbyCache;
  if (!nc || !nc.strokes || nc.strokes.length === 0) return [];

  const bounds = {
    minX: nearX - radius, minY: nearY - radius,
    maxX: nearX + radius, maxY: nearY + radius,
  };

  const shapeHints = collectSurfaceShapes(nc, bounds, 80);
  const surface = buildSurfaceField(nc.strokes, bounds, shapeHints, clamp(Math.round(radius / 2.8), 100, 190));
  const densityMap = buildDensityMap(nc.strokes, bounds, 32);
  const strokeField = buildStrokeField(nc, bounds, clamp(stepLen * 0.95, 5, 22));

  // --- Constants ---
  const TIP_BUDGET = 250;
  const MAX_GLOBAL_ITERATIONS = 3000;
  const EDGE_THRESHOLD = 25;
  const EDGE_FOLLOW_BLEND = 0.7;
  const AVOID_RADIUS = 18;
  const NOISE_SCALE = 0.008;
  const NOISE_STRENGTH = 0.38;
  const MAX_GENERATION = 6;
  const SIZE_SHRINK = 0.82;
  const COLLISION_DIST = stepLen * 1.5;
  const COLOR_GRACE_DIST = stepLen * 30;

  const strokes = [];
  const tips = [];
  let totalBranches = 0;

  // Spatial hash grid for cross-tip avoidance (replaces O(n²) allVinePoints array)
  const VP_CELL = AVOID_RADIUS * 2;
  const vineGrid = new Map(); // "gx,gy" → [{x, y, tipIdx}]
  let vinePointCounter = 0;

  function addVinePoint(x, y, tipIdx) {
    const key = `${Math.floor(x / VP_CELL)},${Math.floor(y / VP_CELL)}`;
    let cell = vineGrid.get(key);
    if (!cell) { cell = []; vineGrid.set(key, cell); }
    cell.push({ x, y, tipIdx });
  }

  function* nearbyVinePoints(x, y) {
    const gx = Math.floor(x / VP_CELL);
    const gy = Math.floor(y / VP_CELL);
    for (let dx = -1; dx <= 1; dx++) {
      for (let dy = -1; dy <= 1; dy++) {
        const cell = vineGrid.get(`${gx + dx},${gy + dy}`);
        if (cell) yield* cell;
      }
    }
  }

  // Diagnostic stats
  const stats = { seeds: 0, branches: 0, deaths: { sdf: 0, boundary: 0, collision: 0, stalled: 0, budget: 0 }, maxSteps: 0, totalPoints: 0 };

  // --- Seed tips ---
  if (mode === 'fill') {
    const faces = shapeHints.length > 0 ? shapeHints : extractPlanarFaces(nc.strokes, bounds);
    for (const face of faces) {
      if (totalBranches >= maxBranches) break;
      const polygon = face.polygon;
      if (!polygon || polygon.length < 3) continue;

      // Compute centroid
      let cx = 0, cy = 0;
      for (const p of polygon) { cx += p.x; cy += p.y; }
      cx /= polygon.length;
      cy /= polygon.length;

      // Get color from nearby stroke
      const nearStroke = findNearestStroke(cx, cy, nc);
      const style = strokeField.styleAt(cx, cy, getStrokeColor(nearStroke), getStrokeBrushSize(nearStroke), getStrokeOpacity(nearStroke), radius * 0.2);
      const srcColor = style.color;
      const srcSize = style.brushSize;
      const rgb = hexToRgb(srcColor);
      const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);

      // Seed tips along polygon boundary pointing inward
      const tipCount = Math.min(Math.ceil(polygon.length / 3), maxBranches - totalBranches, 50);
      const tipStep = Math.max(1, Math.floor(polygon.length / tipCount));
      for (let i = 0; i < polygon.length && totalBranches < maxBranches; i += tipStep) {
        const p = polygon[i];
        const angle = Math.atan2(cy - p.y, cx - p.x);
        tips.push({
          x: p.x, y: p.y, angle,
          sourceAngle: angle,
          h: hsl.h, s: hsl.s, l: hsl.l,
          brushSize: srcSize * 0.8,
          generation: 0, distFromOrigin: 0, stepCount: 0,
          stepsRemaining: TIP_BUDGET,
          points: [{ x: p.x, y: p.y }],
          alive: true, cooldown: 0,
        });
        totalBranches++;
      }
    }
  } else {
    // Grow mode — seed from SDF/topology-derived surface cues instead of every endpoint.
    const growthSeeds = buildSurfaceSeeds(
      nc,
      nearX,
      nearY,
      radius,
      shapeHints,
      surface,
      Math.min(maxBranches, Math.max(14, Math.round(maxBranches * 0.45))),
      { brushScale: 0.8 },
    );

    for (const seed of growthSeeds) {
      if (totalBranches >= maxBranches) break;
      const style = strokeField.styleAt(seed.x, seed.y, seed.color, seed.brushSize, 0.9, radius * 0.22);
      const rgb = hexToRgb(style.color);
      const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
      const angle = Math.atan2(seed.dir.y, seed.dir.x);
      tips.push({
        x: seed.x, y: seed.y, angle,
        sourceAngle: angle,
        h: hsl.h, s: hsl.s, l: hsl.l,
        brushSize: clamp(seed.brushSize * 0.6 + style.brushSize * 0.4, 1, 24),
        generation: 0, distFromOrigin: 0, stepCount: 0,
        stepsRemaining: TIP_BUDGET,
        points: [{ x: seed.x, y: seed.y }],
        alive: true, cooldown: 0,
      });
      totalBranches++;
    }
  }

  if (tips.length === 0) return [];
  stats.seeds = tips.length;

  // --- Growth loop (per-tip step budget) ---
  let globalIterations = 0;
  while (globalIterations < MAX_GLOBAL_ITERATIONS && tips.some(t => t.alive && t.stepsRemaining > 0)) {
    globalIterations++;

    for (let ti = 0; ti < tips.length; ti++) {
      const tip = tips[ti];
      if (!tip.alive || tip.stepsRemaining <= 0) continue;

      tip.stepsRemaining--;
      tip.stepCount++;

      // 1. Base direction — blend between source tangent (momentum) and current heading
      // First ~12 steps strongly follow the source stroke direction, then gradually loosen
      const MOMENTUM_STEPS = 12;
      const momentum = tip.stepCount < MOMENTUM_STEPS
        ? 1 - (tip.stepCount / MOMENTUM_STEPS) * 0.8  // 1.0 → 0.2 over 12 steps
        : 0;
      const srcDx = Math.cos(tip.sourceAngle);
      const srcDy = Math.sin(tip.sourceAngle);
      const curDx = Math.cos(tip.angle);
      const curDy = Math.sin(tip.angle);
      let dx = srcDx * momentum + curDx * (1 - momentum);
      let dy = srcDy * momentum + curDy * (1 - momentum);

      // 2. Surface interaction via signed distance field
      const signedDist = surface.signedDistance(tip.x, tip.y);
      const dist = Math.abs(signedDist);
      const grad = surface.normal(tip.x, tip.y);

      // Kill only when deeply colliding with existing geometry (not just grazing edges).
      if (tip.stepCount > 18 && signedDist < -stepLen * 0.32 && dist < stepLen * 1.1) {
        tip.points.pop();
        tip.alive = false;
        stats.deaths.sdf++;
        continue;
      }

      if (dist < EDGE_THRESHOLD && (Math.abs(grad.x) + Math.abs(grad.y) > 1e-8)) {
        // Tangent = perpendicular to gradient
        const tx = -grad.y;
        const ty = grad.x;
        // Blend toward tangent proportional to proximity
        const blendFactor = (1 - dist / EDGE_THRESHOLD) * EDGE_FOLLOW_BLEND;
        // Pick tangent direction closest to current heading
        const dot = dx * tx + dy * ty;
        const signT = dot >= 0 ? 1 : -1;
        dx = dx * (1 - blendFactor) + signT * tx * blendFactor;
        dy = dy * (1 - blendFactor) + signT * ty * blendFactor;

        // Stronger repulsion when close to strokes (always push away)
        if (dist < stepLen * 3) {
          const repel = (1 - dist / (stepLen * 3)) * 0.8;
          dx += grad.x * repel;
          dy += grad.y * repel;
        }
      }

      // If the tip drifts inside a closed region, push it back outward.
      if (signedDist < 0 && (Math.abs(grad.x) + Math.abs(grad.y) > 1e-8)) {
        const insidePush = clamp((-signedDist) / (stepLen * 2), 0, 1);
        dx += grad.x * insidePush * 1.1;
        dy += grad.y * insidePush * 1.1;
      }

      const strokeSteer = strokeField.steerAlongStroke(
        tip.x,
        tip.y,
        { x: dx, y: dy },
        clamp(stepLen * 3.5, 8, 48),
        clamp(stepLen * 13, 24, 170),
        1.0,
        0.88,
      );
      if (strokeSteer.info) {
        const nearStroke = clamp(1 - strokeSteer.info.dist / (stepLen * 12), 0, 1);
        dx += strokeSteer.x * (0.28 + nearStroke * 0.95);
        dy += strokeSteer.y * (0.28 + nearStroke * 0.95);
      }

      const leashInfo = strokeField.influence(
        tip.x,
        tip.y,
        { x: dx, y: dy },
        clamp(stepLen * 22, 70, 260),
      );
      if (leashInfo) {
        const leashDist = stepLen * 10.5;
        if (leashInfo.dist > leashDist) {
          const pull = clamp((leashInfo.dist - leashDist) / leashDist, 0, 1);
          dx -= leashInfo.normal.x * (0.58 + pull * 1.7);
          dy -= leashInfo.normal.y * (0.58 + pull * 1.7);
        }
      } else if (tip.stepCount > 24) {
        const toCenter = normalizeVec(nearX - tip.x, nearY - tip.y, { x: 1, y: 0 });
        dx += toCenter.x * 0.55;
        dy += toCenter.y * 0.55;
      }

      // 3. Curl noise
      const noiseAngle = noise2d(tip.x * NOISE_SCALE, tip.y * NOISE_SCALE) * Math.PI * 2;
      dx += Math.cos(noiseAngle) * NOISE_STRENGTH;
      dy += Math.sin(noiseAngle) * NOISE_STRENGTH;

      // 4. Cross-tip avoidance (spatial hash, skip own trail)
      let avoidX = 0, avoidY = 0;
      for (const vp of nearbyVinePoints(tip.x, tip.y)) {
        if (vp.tipIdx === ti) continue; // don't avoid own trail
        const adx = tip.x - vp.x;
        const ady = tip.y - vp.y;
        const ad2 = adx * adx + ady * ady;
        if (ad2 < AVOID_RADIUS * AVOID_RADIUS && ad2 > 1) {
          const ad = Math.sqrt(ad2);
          avoidX += (adx / ad) * (1 - ad / AVOID_RADIUS);
          avoidY += (ady / ad) * (1 - ad / AVOID_RADIUS);
        }
      }
      dx += avoidX * 0.5;
      dy += avoidY * 0.5;

      // 5. Density repulsion
      const localDensity = densityMap.get(tip.x, tip.y);
      if (localDensity > 0.3) {
        dx += (noise2d(tip.x * 0.02, globalIterations * 0.1) - 0.5) * localDensity * 0.8;
        dy += (noise2d(globalIterations * 0.1, tip.y * 0.02) - 0.5) * localDensity * 0.8;
      }

      // 6. Normalize and step forward
      const dLen = Math.sqrt(dx * dx + dy * dy);
      if (dLen < 1e-6) { tip.alive = false; stats.deaths.stalled++; continue; }
      dx /= dLen;
      dy /= dLen;

      tip.x += dx * stepLen;
      tip.y += dy * stepLen;
      tip.angle = Math.atan2(dy, dx);
      tip.distFromOrigin += stepLen;
      tip.points.push({ x: tip.x, y: tip.y });

      // Record for cross-tip avoidance (every 3rd point, spatial hash)
      vinePointCounter++;
      if (vinePointCounter % 3 === 0) {
        addVinePoint(tip.x, tip.y, ti);
      }

      // 7. Color drift (HSL random walk) — grace period before drift begins
      const rawDistRatio = clamp(tip.distFromOrigin / (radius * 1.5), 0, 1);
      const distRatio = tip.distFromOrigin < COLOR_GRACE_DIST ? 0 : rawDistRatio;
      const walkWidth = driftRange * distRatio;
      const colorNoise = noise2d(tip.x * 0.01 + globalIterations * 0.3, tip.y * 0.01);
      tip.h = (tip.h + (colorNoise - 0.5) * walkWidth * 60 + 360) % 360;
      tip.s = clamp(tip.s + (colorNoise - 0.5) * walkWidth * 15, 10, 95);
      // Slight upward bias (+0.3) prevents vines from going dark too quickly
      tip.l = clamp(tip.l + (noise2d(globalIterations * 0.3, tip.x * 0.01) - 0.2) * walkWidth * 10, 35, 85);

      // 8. Branch decision (suppressed during momentum phase)
      if (tip.cooldown > 0) {
        tip.cooldown--;
      } else if (tip.stepCount <= MOMENTUM_STEPS) {
        // No branching during momentum phase — let vine establish direction first
      } else {
        const sparseness = 1 - localDensity;
        const geomInterest = strokeSteer.info
          ? clamp(
              Math.abs(strokeSteer.error)
              + (1 - Math.min(strokeSteer.info.dist / (stepLen * 10), 1)) * 0.5,
              0.2,
              1.6,
            )
          : 0.65;
        const effectiveProb = branchProb
          * (1 + distRatio * 0.5)
          * (1 + sparseness * 0.3)
          * (tip.generation < 2 ? 1.0 : 0.5)
          * (0.8 + geomInterest * 0.55);
        if (noise2d(globalIterations * 1.7 + ti * 3.1, tip.x * 0.05) < effectiveProb
            && tip.generation < MAX_GENERATION
            && totalBranches < maxBranches) {
          // Fork: ±50-90° from current heading (wide spread)
          const forkAngle = (50 + noise2d(ti * 2.3, globalIterations * 1.1) * 40) * Math.PI / 180;
          const forkSign = noise2d(globalIterations * 0.9, ti * 1.7) > 0.5 ? 1 : -1;
          let branchAngle = tip.angle + forkSign * forkAngle;
          if (strokeSteer.info) {
            const tanA = Math.atan2(strokeSteer.info.tangent.y, strokeSteer.info.tangent.x);
            const geomBlend = clamp(0.25 + geomInterest * 0.2, 0.2, 0.55);
            const tangentFork = tanA + forkSign * (Math.PI * (0.42 + geomInterest * 0.08));
            branchAngle = branchAngle * (1 - geomBlend) + tangentFork * geomBlend;
          }
          tips.push({
            x: tip.x, y: tip.y,
            angle: branchAngle,
            sourceAngle: branchAngle, // branch inherits its fork angle as momentum
            h: tip.h, s: tip.s, l: tip.l,
            brushSize: tip.brushSize * SIZE_SHRINK,
            generation: tip.generation + 1,
            distFromOrigin: tip.distFromOrigin, stepCount: 0,
            stepsRemaining: TIP_BUDGET,
            points: [{ x: tip.x, y: tip.y }],
            alive: true, cooldown: 0,
          });
          totalBranches++;
          stats.branches++;
          tip.cooldown = 8;
        }
      }

      // 9. Death conditions
      const offX = tip.x - nearX;
      const offY = tip.y - nearY;
      if (offX * offX + offY * offY > radius * radius * 6.25) {  // 2.5× radius boundary
        tip.alive = false;
        stats.deaths.boundary++;
        continue;
      }
      if (tip.stepCount > 26) {
        const detachInfo = strokeField.influence(
          tip.x,
          tip.y,
          { x: Math.cos(tip.angle), y: Math.sin(tip.angle) },
          clamp(stepLen * 24, 80, 300),
        );
        if (!detachInfo || detachInfo.dist > stepLen * 20) {
          tip.alive = false;
          stats.deaths.boundary++;
          continue;
        }
      }
      // Cross-tip collision (skip during momentum phase — seeds start near each other)
      if (tip.stepCount > 20) {
        for (const vp of nearbyVinePoints(tip.x, tip.y)) {
          if (vp.tipIdx === ti) continue;
          const cdx = tip.x - vp.x;
          const cdy = tip.y - vp.y;
          if (cdx * cdx + cdy * cdy < COLLISION_DIST * COLLISION_DIST * 0.65) {
            tip.alive = false;
            stats.deaths.collision++;
            break;
          }
        }
      }

      // Track max steps for diagnostics
      if (tip.stepCount > stats.maxSteps) stats.maxSteps = tip.stepCount;
    }
  }

  // Count budget exhaustions
  for (const tip of tips) {
    if (tip.stepsRemaining <= 0 && tip.alive) {
      stats.deaths.budget++;
      tip.alive = false;
    }
  }

  // --- Convert trails to strokes ---
  for (const tip of tips) {
    if (tip.points.length < 3) continue;
    const color = hslToHex(tip.h, tip.s, tip.l);
    const opacity = clamp(0.9 - tip.generation * 0.12, 0.3, 0.9);
    strokes.push(makeStroke(tip.points, color, tip.brushSize, opacity, 'taper'));
    stats.totalPoints += tip.points.length;
  }

  return strokes;
}


path: primitives/decorative/clockwork-nebula.mjs
content: /**
 * The Clockwork Nebula — A cosmic composition of stars, spirograph gears, and nebula dust.
 *
 * Community primitive for ClawDraw.
 */

import { makeStroke, samplePalette, clamp, noise2d } from './helpers.mjs';

export const METADATA = {
  name: 'clockworkNebula',
  description: 'A cosmic scene with starfield, spirograph gears, and nebula dust',
  category: 'community',
  author: 'Pablo_PiCLAWsso',
  parameters: {
    cx: { type: 'number', required: true, description: 'Center X' },
    cy: { type: 'number', required: true, description: 'Center Y' },
    size: { type: 'number', default: 3000, description: 'Overall size of the nebula' },
    stars: { type: 'number', default: 1000, description: 'Number of stars' },
    gears: { type: 'number', default: 15, description: 'Number of spirograph gears' },
    dust: { type: 'number', default: 50, description: 'Amount of nebula dust' },
    palette: { type: 'string', default: 'turbo', description: 'Color palette (turbo recommended)' },
  },
};

// Inline spirograph helper to keep this primitive self-contained
function generateSpiro(cx, cy, outerR, innerR, traceR, revolutions, color, brushSize) {
  const steps = revolutions * 100;
  const diff = outerR - innerR;
  const pts = [];
  for (let i = 0; i <= steps; i++) {
    const t = (i / steps) * revolutions * Math.PI * 2;
    const x = cx + diff * Math.cos(t) + traceR * Math.cos(t * diff / innerR);
    const y = cy + diff * Math.sin(t) - traceR * Math.sin(t * diff / innerR);
    pts.push({ x, y });
  }
  
  // Split long strokes
  const result = [];
  const MAX = 4000;
  for(let i=0; i<pts.length; i+=MAX) {
    const chunk = pts.slice(i, i+MAX);
    if(chunk.length > 2) {
      result.push(makeStroke(chunk, color, brushSize, 0.8, 'taperBoth'));
    }
  }
  return result;
}

export function clockworkNebula(cx, cy, size, stars, gears, dust, palette) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  size = clamp(Number(size) || 3000, 500, 10000);
  stars = clamp(Number(stars) || 1000, 100, 5000);
  gears = clamp(Number(gears) || 15, 1, 50);
  dust = clamp(Number(dust) || 50, 10, 200);
  palette = palette || 'turbo';

  const strokes = [];

  // 1. Starfield (Stippling)
  for(let i=0; i<stars; i++) {
    // Gaussian-ish distribution for density at center
    const r = (Math.random() + Math.random() + Math.random()) / 3 * size;
    const angle = Math.random() * Math.PI * 2;
    const x = cx + Math.cos(angle) * r;
    const y = cy + Math.sin(angle) * r;
    
    const t = 1 - (r / size); // Brighter/Different color at center
    strokes.push(makeStroke(
        [{x, y}, {x: x+1, y: y+1}], 
        samplePalette(palette, clamp(t, 0, 1)), 
        Math.random() * 3 + 1, 
        0.6, 
        'flat'
    ));
  }

  // 2. Gears (Spirographs)
  const gearCenters = [];
  for(let i=0; i<gears; i++) {
    const r = 100 + Math.random() * 400;
    const gx = cx + (Math.random() - 0.5) * size * 0.8;
    const gy = cy + (Math.random() - 0.5) * size * 0.8;
    gearCenters.push({x: gx, y: gy});
    
    const gearStrokes = generateSpiro(
      gx, gy,
      r,
      r * (0.2 + Math.random() * 0.6), // innerR
      r * 0.5, // offset
      20, // revolutions
      samplePalette(palette, Math.random()),
      3
    );
    strokes.push(...gearStrokes);
  }

  // 3. Nebula Dust (Strange Attractors - Lorenz-like traces)
  for(let i=0; i<dust; i++) {
    const scale = 30 + Math.random() * 20;
    const dcx = cx + (Math.random() - 0.5) * size;
    const dcy = cy + (Math.random() - 0.5) * size;
    const c = samplePalette(palette, Math.random());
    
    let x = 0.1, y = 0, z = 0;
    const dt = 0.01;
    const pts = [];
    // Simple Lorenz attractor trace
    for(let j=0; j<500; j++) {
      const dx = 10 * (y - x);
      const dy = x * (28 - z) - y;
      const dz = x * y - (8/3) * z;
      x += dx * dt; y += dy * dt; z += dz * dt;
      pts.push({ x: dcx + x * scale, y: dcy + y * scale });
    }
    strokes.push(makeStroke(pts, c, 2, 0.4, 'taper'));
  }

  // 4. Web Connections (Electric Arcs between gears)
  for(let i=0; i<gearCenters.length; i++) {
    for(let j=i+1; j<gearCenters.length; j++) {
      const p1 = gearCenters[i];
      const p2 = gearCenters[j];
      const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
      
      if (dist < 800) {
        const midX = (p1.x + p2.x) / 2;
        const midY = (p1.y + p2.y) / 2;
        const cpX = midX + (Math.random() - 0.5) * 200;
        const cpY = midY + (Math.random() - 0.5) * 200;
        
        const pts = [];
        for(let t=0; t<=1; t+=0.05) {
            const xx = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cpX + t*t*p2.x;
            const yy = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cpY + t*t*p2.y;
            pts.push({x: xx, y: yy});
        }
        strokes.push(makeStroke(pts, '#ffffff', 1, 0.5, 'pulse'));
      }
    }
  }

  return strokes;
}


path: primitives/decorative/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/decorative/matrix-rain.mjs
content: /**
 * Matrix Rain — Digital rain effect with glitch offsets.
 *
 * Community primitive for ClawDraw.
 */

import { makeStroke, clamp } from './helpers.mjs';

export const METADATA = {
  name: 'matrixRain',
  description: 'Digital rain effect with glitch offsets',
  category: 'community',
  author: 'Pablo_PiCLAWsso',
  parameters: {
    cx: { type: 'number', required: true, description: 'Center X' },
    cy: { type: 'number', required: true, description: 'Center Y' },
    width: { type: 'number', default: 1000, description: 'Field width' },
    height: { type: 'number', default: 1000, description: 'Field height' },
    density: { type: 'number', default: 50, description: 'Number of drops' },
    color: { type: 'string', default: '#00ff00', description: 'Color' },
    glitch: { type: 'number', default: 0.1, description: 'Glitch probability (0-1)' },
  },
};

export function matrixRain(cx, cy, width, height, density, color, glitch) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  width = clamp(Number(width) || 1000, 100, 5000);
  height = clamp(Number(height) || 1000, 100, 5000);
  density = clamp(Number(density) || 50, 10, 500);
  color = color || '#00ff00';
  glitch = clamp(Number(glitch) || 0.1, 0, 1);

  const strokes = [];

  for (let i = 0; i < density; i++) {
    const rx = cx + (Math.random() - 0.5) * width;
    const ry = cy + (Math.random() - 0.5) * height;
    
    const pts = [];
    const len = 50 + Math.random() * 200; // Random drop length
    const step = 8;
    
    for (let j = 0; j < len; j += step) {
      // Glitch offset
      const dx = (Math.random() < glitch) ? (Math.random() - 0.5) * 15 : 0;
      pts.push({ x: rx + dx, y: ry + j });
    }
    
    strokes.push(makeStroke(
      pts,
      color,
      2,
      0.6 + Math.random() * 0.4, // Varied opacity
      'flick' // Taper end
    ));
  }

  return strokes;
}


path: primitives/decorative/starburst.mjs
content: /**
 * Starburst — radial sunburst with alternating long/short pointed rays.
 *
 * Community primitive for ClawDraw.
 */

import { clamp, makeStroke, samplePalette } from './helpers.mjs';

export const METADATA = {
  name: 'starburst',
  description: 'Radial sunburst with alternating triangular rays colored by angle',
  category: 'community',
  author: 'kajukabla',
  parameters: {
    cx:            { type: 'number', required: true, description: 'Center X' },
    cy:            { type: 'number', required: true, description: 'Center Y' },
    outerRadius:   { type: 'number', required: false, default: 185, description: 'Outer ray radius' },
    rays:          { type: 'number', required: false, default: 24, description: 'Number of rays (8-60)' },
    innerRadius:   { type: 'number', required: false, default: 30, description: 'Inner circle radius' },
    shortRatio:    { type: 'number', required: false, default: 0.6, description: 'Short ray length ratio (0.3-0.9)' },
    color:         { type: 'string', required: false, default: '#ffffff', description: 'Hex color (ignored if palette set)' },
    brushSize:     { type: 'number', required: false, default: 3, description: 'Brush width (3-100)' },
    opacity:       { type: 'number', required: false, default: 0.85, description: 'Stroke opacity (0-1)' },
    palette:       { type: 'string', required: false, description: 'Palette name (magma, plasma, viridis, turbo, inferno)' },
    pressureStyle: { type: 'string', required: false, default: 'flat', description: 'Pressure style' },
  },
};

/**
 * Generate a radial starburst / sunburst pattern.
 *
 * Alternating long and short pointed triangular rays emanate from a central
 * circle. Each ray is a filled triangular wedge drawn as an outline. Rays
 * are colored by their angle around the circle via the palette.
 *
 * @returns {Array} Array of stroke objects
 */
export function starburst(cx, cy, outerRadius, rays, innerRadius, shortRatio, color, brushSize, opacity, palette, pressureStyle) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  outerRadius = clamp(Number(outerRadius) || 185, 30, 500);
  rays = clamp(Math.round(Number(rays) || 24), 8, 60);
  innerRadius = clamp(Number(innerRadius) || 30, 5, outerRadius * 0.5);
  shortRatio = clamp(Number(shortRatio) || 0.6, 0.3, 0.9);
  brushSize = clamp(Number(brushSize) || 3, 3, 100);
  opacity = clamp(Number(opacity) || 0.85, 0.01, 1);

  const result = [];
  const anglePerRay = (Math.PI * 2) / rays;
  const halfAngle = anglePerRay * 0.45; // slight gap between rays

  // --- Draw rays as triangular wedges ---
  for (let i = 0; i < rays && result.length < 180; i++) {
    const centerAngle = i * anglePerRay;
    const isLong = i % 2 === 0;
    const rayLen = isLong ? outerRadius : outerRadius * shortRatio;

    // Triangle: two base points on inner circle, one tip point at ray length
    const leftAngle = centerAngle - halfAngle;
    const rightAngle = centerAngle + halfAngle;

    const baseLeft = {
      x: cx + Math.cos(leftAngle) * innerRadius,
      y: cy + Math.sin(leftAngle) * innerRadius,
    };
    const baseRight = {
      x: cx + Math.cos(rightAngle) * innerRadius,
      y: cy + Math.sin(rightAngle) * innerRadius,
    };
    const tip = {
      x: cx + Math.cos(centerAngle) * rayLen,
      y: cy + Math.sin(centerAngle) * rayLen,
    };

    // Draw wedge outline: left edge -> tip -> right edge -> back to base
    const pts = [];
    const steps = 8;

    // Left side: base left -> tip
    for (let s = 0; s <= steps; s++) {
      const frac = s / steps;
      pts.push({
        x: baseLeft.x + (tip.x - baseLeft.x) * frac,
        y: baseLeft.y + (tip.y - baseLeft.y) * frac,
      });
    }
    // Right side: tip -> base right
    for (let s = 1; s <= steps; s++) {
      const frac = s / steps;
      pts.push({
        x: tip.x + (baseRight.x - tip.x) * frac,
        y: tip.y + (baseRight.y - tip.y) * frac,
      });
    }
    // Close: base right -> base left (arc along inner circle)
    for (let s = 1; s <= 4; s++) {
      const frac = s / 4;
      const a = rightAngle + (leftAngle + anglePerRay - rightAngle) * frac;
      pts.push({
        x: cx + Math.cos(a) * innerRadius,
        y: cy + Math.sin(a) * innerRadius,
      });
    }

    const t = i / rays;
    const col = palette ? samplePalette(palette, t) : (color || '#ffffff');
    result.push(makeStroke(pts, col, brushSize, opacity, pressureStyle));
  }

  // --- Inner circle outline ---
  const circlePts = [];
  const circleSteps = 48;
  for (let s = 0; s <= circleSteps; s++) {
    const a = (s / circleSteps) * Math.PI * 2;
    circlePts.push({
      x: cx + Math.cos(a) * innerRadius,
      y: cy + Math.sin(a) * innerRadius,
    });
  }
  const circleCol = palette ? samplePalette(palette, 0.5) : (color || '#ffffff');
  result.push(makeStroke(circlePts, circleCol, brushSize + 1, opacity, pressureStyle));

  // --- Outer boundary circle (connects ray tips) ---
  const outerPts = [];
  for (let s = 0; s <= circleSteps; s++) {
    const a = (s / circleSteps) * Math.PI * 2;
    // Undulate between long and short ray lengths
    const rayIdx = (a / anglePerRay);
    const frac = rayIdx - Math.floor(rayIdx);
    const isLongA = Math.floor(rayIdx) % 2 === 0;
    const isLongB = (Math.floor(rayIdx) + 1) % 2 === 0;
    const rA = isLongA ? outerRadius : outerRadius * shortRatio;
    const rB = isLongB ? outerRadius : outerRadius * shortRatio;
    // Smooth interpolation between tip radii
    const smoothFrac = frac * frac * (3 - 2 * frac);
    const r = rA + (rB - rA) * smoothFrac;
    outerPts.push({
      x: cx + Math.cos(a) * r,
      y: cy + Math.sin(a) * r,
    });
  }
  // Split outer boundary into colored segments
  const segCount = Math.min(24, rays);
  const chunkSize = Math.ceil(outerPts.length / segCount);
  for (let i = 0; i < segCount && result.length < 200; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize + 1, outerPts.length);
    const pts = outerPts.slice(start, end);
    if (pts.length < 2) continue;
    const t = i / Math.max(segCount - 1, 1);
    const col = palette ? samplePalette(palette, t) : (color || '#ffffff');
    result.push(makeStroke(pts, col, brushSize * 0.7, opacity * 0.5, pressureStyle));
  }

  return result.slice(0, 200);
}


path: primitives/fills/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/flow/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/fractals/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/noise/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/noise/voronoi-grid.mjs
content: /**
 * Voronoi Grid — A Voronoi-style cellular grid generated by scanning.
 *
 * Community primitive for ClawDraw.
 */

import { makeStroke, samplePalette, clamp } from './helpers.mjs';

export const METADATA = {
  name: 'voronoiGrid',
  description: 'Voronoi-style cellular grid generated by edge scanning',
  category: 'community',
  author: 'Pablo_PiCLAWsso',
  parameters: {
    cx: { type: 'number', required: true, description: 'Center X' },
    cy: { type: 'number', required: true, description: 'Center Y' },
    width: { type: 'number', default: 1000, description: 'Width' },
    height: { type: 'number', default: 1000, description: 'Height' },
    cells: { type: 'number', default: 20, description: 'Number of cells' },
    palette: { type: 'string', default: 'magma', description: 'Color palette' },
  },
};

export function voronoiGrid(cx, cy, width, height, cells, palette) {
  cx = Number(cx) || 0;
  cy = Number(cy) || 0;
  width = clamp(Number(width) || 1000, 100, 5000);
  height = clamp(Number(height) || 1000, 100, 5000);
  cells = clamp(Number(cells) || 20, 5, 100);
  palette = palette || 'magma';

  const strokes = [];
  const seeds = [];

  // Generate seeds
  for (let i = 0; i < cells; i++) {
    seeds.push({
      x: cx + (Math.random() - 0.5) * width,
      y: cy + (Math.random() - 0.5) * height,
      t: Math.random()
    });
  }

  // Scan grid for edges
  const step = 15;
  for (let x = cx - width / 2; x < cx + width / 2; x += step) {
    for (let y = cy - height / 2; y < cy + height / 2; y += step) {
      let minDist = Infinity;
      let closest = null;
      let secondDist = Infinity;

      for (const s of seeds) {
        const d = Math.hypot(x - s.x, y - s.y);
        if (d < minDist) {
          secondDist = minDist;
          minDist = d;
          closest = s;
        } else if (d < secondDist) {
          secondDist = d;
        }
      }

      // Edge detection threshold
      if (secondDist - minDist < step * 0.6) {
        const h = 5 + Math.random() * 10;
        const c = samplePalette(palette, closest.t * 0.5);
        
        strokes.push(makeStroke(
          [{ x, y }, { x, y: y - h }],
          c,
          3,
          0.8,
          'flat'
        ));
      }
    }
  }

  return strokes;
}


path: primitives/organic/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/shapes/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/simulation/helpers.mjs
content: export * from '../helpers.mjs';


path: primitives/utility/helpers.mjs
content: export * from '../helpers.mjs';


Security scan verdicts — ClawScan: clean | static analysis:  | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)