From 63213c0d9b3fcc9f4e775d3b0cb49458d05cc8e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:42:22 +0000 Subject: [PATCH 01/15] Add external sources for automatic design generation New Sources tab and five new patterns that build designs from outside data: - Field sources: Flow field, Wave lines, Contour map and Hatch shading can follow a photo or a grid (CSV matrix, ESRI .asc, heightmap) instead of noise - Route pattern: GPX / GeoJSON files and OpenStreetMap streets (Overpass), with smoothing, parallel copies and a rectangle or circle frame - Data lines pattern: CSV / JSON series as ridgelines, rings or a spiral; weather from Open-Meteo; Microsoft 365 report CSVs are put in date order - Terrain: Terrarium elevation tiles around a place, drawn as contours - SVG import pattern with its own SVG reader and even-odd hatch fill - Audio pattern: WAV / AIFF as waveform rows, spiral or spectrum ridgeline - Text pattern with single-line Hershey fonts (sans, serif, script) - Web addresses with a local download cache, so presets stay reproducible - Watch folder that loads new files and exports them automatically - Text to seed / design: the same name always gives the same design - AI design: a description becomes a preset through the Claude API - Command-line mode (--preset, --input, --out, --batch, --watch, ...) Presets are now version 2: they also store the sources, each layer's field and text, and accept parameters by name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016g4XoUPwffpdezjFy4s5DC --- .gitignore | 3 + CONTRIBUTING.md | 2 +- GenerativeLineArt/AiDesign.pde | 202 +++ GenerativeLineArt/Audio.pde | 174 +++ GenerativeLineArt/Cli.pde | 150 ++ GenerativeLineArt/DataPatterns.pde | 241 +++ GenerativeLineArt/GenerativeLineArt.pde | 275 +++- GenerativeLineArt/Sources.pde | 1003 +++++++++++++ GenerativeLineArt/SvgImport.pde | 345 +++++ GenerativeLineArt/TextFont.pde | 100 ++ .../data/HERSHEY_FONTS_NOTICE.txt | 17 + GenerativeLineArt/data/futural.jhf | 96 ++ GenerativeLineArt/data/scripts.jhf | 96 ++ GenerativeLineArt/data/timesr.jhf | 96 ++ .../presets/data_lines_ridgeline.json | 1309 +++++++++++++++++ .../presets/text_birthday_card.json | 1309 +++++++++++++++++ README.md | 70 +- 17 files changed, 5435 insertions(+), 53 deletions(-) create mode 100644 GenerativeLineArt/AiDesign.pde create mode 100644 GenerativeLineArt/Audio.pde create mode 100644 GenerativeLineArt/Cli.pde create mode 100644 GenerativeLineArt/DataPatterns.pde create mode 100644 GenerativeLineArt/Sources.pde create mode 100644 GenerativeLineArt/SvgImport.pde create mode 100644 GenerativeLineArt/TextFont.pde create mode 100644 GenerativeLineArt/data/HERSHEY_FONTS_NOTICE.txt create mode 100644 GenerativeLineArt/data/futural.jhf create mode 100644 GenerativeLineArt/data/scripts.jhf create mode 100644 GenerativeLineArt/data/timesr.jhf create mode 100644 GenerativeLineArt/presets/data_lines_ridgeline.json create mode 100644 GenerativeLineArt/presets/text_birthday_card.json diff --git a/.gitignore b/.gitignore index 00624e2..54aa87d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Exported plots GenerativeLineArt/exports/*.svg +# Downloads (web addresses, streets, terrain, weather) cached by the sketch +GenerativeLineArt/cache/ + # Personal quick-saved presets (P key); curated examples are committed by name GenerativeLineArt/presets/preset_*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffb5c6b..0f58ba2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ Use the [issue templates](https://github.com/Murphy23x/PlotterGraph/issues/new/c - Keep it dependency-free: no extra Processing libraries. - Match the existing code style (2-space indentation, short comments where the intent isn't obvious). -- **Adding a pattern:** add its name to `PATTERN_NAMES` and add its parameters and generator alongside the existing ones. Existing preset files store parameters by pattern name and by position, so don't rename existing patterns, and add new parameters at the end of a pattern's list. +- **Adding a pattern:** add its name to the end of `PATTERN_NAMES` and add its parameters and generator alongside the existing ones (patterns that draw external data live in the `DataPatterns`, `SvgImport`, `Audio` and `TextFont` tabs). Existing preset files store parameters by pattern name and by position, so don't rename existing patterns, and add new parameters at the end of a pattern's list. - **Adding an example preset:** save it with the `Save preset...` button, give it a descriptive name (not `preset_.json`, which is git-ignored), and put it in `GenerativeLineArt/presets/`. ## License diff --git a/GenerativeLineArt/AiDesign.pde b/GenerativeLineArt/AiDesign.pde new file mode 100644 index 0000000..8c7a014 --- /dev/null +++ b/GenerativeLineArt/AiDesign.pde @@ -0,0 +1,202 @@ +// AI design: describe a design in words, Claude returns a preset. +// ------------------------------------------------------------------ +// Needs an Anthropic API key in the ANTHROPIC_API_KEY environment variable +// (set it before starting Processing; ANTHROPIC_BASE_URL optionally points +// at a gateway). The prompt, the list of patterns and +// their parameters, and the names of loaded sources are sent to the +// Anthropic API; the answer is a JSON design (structured output) that is +// applied like a preset, so Undo takes it back. + +final String AI_MODEL = "claude-opus-5-5"; +String aiUrl() { // ANTHROPIC_BASE_URL points at a gateway or proxy, as in the SDKs + String b = System.getenv("ANTHROPIC_BASE_URL"); + if (b == null || b.trim().isEmpty()) b = "https://api.anthropic.com"; + return b.trim().replaceAll("/+$", "") + "/v1/messages"; +} +String aiSummary = ""; + +String aiKey() { + String k = System.getenv("ANTHROPIC_API_KEY"); + return k == null || k.trim().isEmpty() ? null : k.trim(); +} + +String aiInfo() { + if (aiKey() == null) return "ANTHROPIC_API_KEY is not set: start Processing\nfrom a shell where it is (see README)."; + return aiSummary.isEmpty() ? "Example: \"calm ocean waves in two blues, A3 landscape\"" : shorten(aiSummary, 120); +} + +void aiGenerate(String prompt0) { + final String prompt = prompt0.trim(); + if (prompt.isEmpty()) { setStatus("Describe the design first"); return; } + final String key = aiKey(); + if (key == null) { setStatus("Set ANTHROPIC_API_KEY to use AI design (see README)"); return; } + final String body = aiRequest(prompt).format(-1); + runAsync("Designing with Claude", new Task() { + public void run() throws Exception { + byte[] b = httpRequest(aiUrl(), body, new String[] { + "x-api-key", key, "anthropic-version", "2026-10-21", "content-type", "application/json", + "anthropic-beta", "server-side-fallback-2026-07-01" }); + JSONObject res = parseJSONObject(new String(b, "UTF-8")); + String stop = res.getString("stop_reason", ""); + if (stop.equals("refusal")) throw new Exception("the request was declined; try wording it differently"); + if (stop.equals("max_tokens")) throw new Exception("the answer was cut off; try a simpler description"); + String text = ""; + JSONArray content = res.getJSONArray("content"); + for (int i = 0; i < content.size(); i++) { + JSONObject c = content.getJSONObject(i); + if (c.getString("type", "").equals("text")) text += c.getString("text", ""); + } + final JSONObject design = parseJSONObject(text); + if (design == null) throw new Exception("no design in the answer"); + onMain(new Runnable() { public void run() { applyAiDesign(design); } }); + } + }); +} + +JSONObject aiRequest(String prompt) { + JSONObject req = new JSONObject(); + req.setString("model", AI_MODEL); + req.setInt("max_tokens", 16000); + req.setString("fallbacks", "default"); + JSONObject oc = new JSONObject(); + oc.setString("effort", "low"); + JSONObject fmt = new JSONObject(); + fmt.setString("type", "json_schema"); + fmt.setJSONObject("schema", aiSchema()); + oc.setJSONObject("format", fmt); + req.setJSONObject("output_config", oc); + req.setString("system", aiSystemPrompt()); + JSONArray msgs = new JSONArray(); + JSONObject m = new JSONObject(); + m.setString("role", "user"); + m.setString("content", prompt); + msgs.append(m); + req.setJSONArray("messages", msgs); + return req; +} + +String aiSystemPrompt() { + StringBuilder sb = new StringBuilder(); + sb.append("You design artwork for PlotterGraph, a generative line-art tool for pen plotters. ") + .append("A design has 1 to 4 layers; each layer is drawn by one pen, so give layers different pen colours unless the user asks otherwise. ") + .append("Layers are drawn on top of each other on the same page, so combine patterns that work together. ") + .append("For each layer pick a pattern, a seed (0-9999), a pen colour, a pen width in mm (0.2-0.8 is typical), a scale (0.1-2, usually 1) and a rotation in degrees. ") + .append("In 'params' list only the parameters you want to change, by their exact name; the rest keep their defaults. ") + .append("Choice parameters take the option index (0 = first option); toggles take 0 or 1. Stay inside the given ranges. ") + .append("Put the words for the Text pattern in 'text' (use | for a new line); leave 'text' empty for other patterns. ") + .append("Image, Route, Data lines, SVG import and Audio draw a loaded source (or a demo when nothing is loaded): ") + .append("use them only when the user asks for photos, maps, data, an SVG or sound. ") + .append("Write 'summary' as one short sentence describing the design, in the user's language.\n\n"); + sb.append("Loaded sources: ").append(loadedSourcesText()).append("\n"); + sb.append("Current paper: ").append(PAPER_NAMES[pPaper.i()]).append(pLandscape.on() ? " landscape" : " portrait").append("\n\n"); + sb.append("Patterns and their parameters (name [min..max] default):\n"); + for (int m = 0; m < PATTERN_NAMES.length; m++) { + sb.append("- ").append(PATTERN_NAMES[m]).append(": "); + ArrayList ps = makeParams(null, m); + for (int k = 0; k < ps.size(); k++) { + Param p = ps.get(k); + if (k > 0) sb.append("; "); + sb.append(p.label); + if (p.type == 1) sb.append(" [toggle] ").append(p.i()); + else if (p.type == 2) { + sb.append(" ["); + for (int o = 0; o < p.opts.length; o++) sb.append(o > 0 ? ", " : "").append(o).append('=').append(p.opts[o]); + sb.append("] ").append(p.i()); + } else sb.append(" [").append(fmtNum(p.min)).append("..").append(fmtNum(p.max)).append("] ").append(fmtNum(p.def)); + } + sb.append('\n'); + } + return sb.toString(); +} + +String fmtNum(float v) { + if (v == round(v)) return str(round(v)); + String s = String.format(Locale.US, "%.4g", v); + return s.contains("e") ? s : s.replaceAll("0+$", "").replaceAll("\\.$", ""); +} + +String loadedSourcesText() { + ArrayList s = new ArrayList(); + if (photo != null) s.add("photo " + new File(photoPath).getName()); + if (geoLines != null) s.add("route " + geoName); + if (dataSeries != null) s.add("data " + dataName); + if (svgShapes != null) s.add("SVG " + svgName); + if (audioSamples != null) s.add("audio " + audioName); + if (gridVals != null) s.add("field grid " + gridName + " (fields: Flow field, Wave lines, Contour map, Hatch shading)"); + return s.isEmpty() ? "none" : join(s.toArray(new String[0]), "; "); +} + +JSONObject aiSchema() { + JSONObject param = obj(new String[] { "name", "value" }, new JSONObject[] { type("string"), type("number") }); + JSONObject layer = obj( + new String[] { "pattern", "seed", "penColour", "penWidth", "scale", "rotate", "text", "params" }, + new JSONObject[] { enumOf(PATTERN_NAMES), type("integer"), enumOf(PEN_NAMES), type("number"), type("number"), + type("number"), type("string"), arrayOf(param) }); + return obj(new String[] { "summary", "paper", "landscape", "layers" }, + new JSONObject[] { type("string"), enumOf(PAPER_NAMES), type("boolean"), arrayOf(layer) }); +} + +JSONObject type(String t) { JSONObject o = new JSONObject(); o.setString("type", t); return o; } + +JSONObject enumOf(String[] vals) { + JSONObject o = type("string"); + JSONArray a = new JSONArray(); + for (String v : vals) a.append(v); + o.setJSONArray("enum", a); + return o; +} + +JSONObject arrayOf(JSONObject items) { JSONObject o = type("array"); o.setJSONObject("items", items); return o; } + +JSONObject obj(String[] keys, JSONObject[] props) { + JSONObject o = type("object"); + JSONObject p = new JSONObject(); + JSONArray req = new JSONArray(); + for (int i = 0; i < keys.length; i++) { p.setJSONObject(keys[i], props[i]); req.append(keys[i]); } + o.setJSONObject("properties", p); + o.setJSONArray("required", req); + o.setBoolean("additionalProperties", false); + return o; +} + +// Turns the answer into a preset (keeping the page and output settings). +void applyAiDesign(JSONObject d) { + JSONObject root = new JSONObject(); + root.setString("paper", d.getString("paper", PAPER_NAMES[pPaper.i()])); + root.setBoolean("landscape", d.getBoolean("landscape", pLandscape.on())); + root.setFloat("margin", pMargin.val); + root.setBoolean("optimizeTravel", pOptimize.on()); + root.setBoolean("filePerLayer", pSeparate.on()); + root.setString("maskFont", maskFontName); + JSONArray in = d.getJSONArray("layers"), outL = new JSONArray(); + for (int i = 0; i < min(NUM_LAYERS, in.size()); i++) { + JSONObject a = in.getJSONObject(i), o = new JSONObject(); + String pat = a.getString("pattern", PATTERN_NAMES[0]); + o.setBoolean("enabled", true); + o.setString("pattern", pat); + o.setInt("seed", Math.floorMod(a.getInt("seed", 1), 10000)); + o.setString("penColour", a.getString("penColour", PEN_NAMES[i % PEN_NAMES.length])); + o.setFloat("penWidth", a.getFloat("penWidth", 0.4)); + o.setFloat("scale", a.getFloat("scale", 1)); + o.setFloat("rotate", a.getFloat("rotate", 0)); + o.setString("text", a.getString("text", "")); + o.setString("field", layers[i].pMode.i() == indexOf(PATTERN_NAMES, pat) ? FIELD_NAMES[layers[i].pField.i()] : "Noise"); + JSONObject named = new JSONObject(); + JSONArray ps = a.getJSONArray("params"); + for (int k = 0; k < ps.size(); k++) named.setFloat(ps.getJSONObject(k).getString("name", ""), ps.getJSONObject(k).getFloat("value", 0)); + JSONObject pp = new JSONObject(); + pp.setJSONObject(pat, named); + o.setJSONObject("params", pp); + outL.append(o); + } + root.setJSONArray("layers", outL); + // a pattern's other parameters go back to their defaults, so the design matches the answer + for (int i = 0; i < min(NUM_LAYERS, in.size()); i++) { + int m = indexOf(PATTERN_NAMES, in.getJSONObject(i).getString("pattern", "")); + if (m >= 0) for (Param p : layers[i].modeParams.get(m)) p.set(p.def); + } + if (!applyPreset(root)) return; + aiSummary = d.getString("summary", ""); + setStatus("AI design: " + (aiSummary.isEmpty() ? "applied" : aiSummary)); + if (syncJobs) println("AI design: " + aiSummary); +} diff --git a/GenerativeLineArt/Audio.pde b/GenerativeLineArt/Audio.pde new file mode 100644 index 0000000..78f8510 --- /dev/null +++ b/GenerativeLineArt/Audio.pde @@ -0,0 +1,174 @@ +// Audio pattern (33): a WAV / AIFF / AU file drawn as waveform rows, a +// waveform spiral, or a spectrum "ridgeline" (one line per time slice). +// Read with javax.sound, so no extra library is needed. + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; + +float[] audioSamples; // mono, -1..1 +int audioRate = 22050; +String audioName = "", audioPath = ""; + +boolean loadAudioFile(File f) { + try { + AudioInputStream in = AudioSystem.getAudioInputStream(f); + AudioFormat src = in.getFormat(); + int ch = max(1, src.getChannels()); + AudioFormat fmt = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, src.getSampleRate(), 16, ch, ch * 2, src.getSampleRate(), false); + byte[] b = readAllBytes(AudioSystem.getAudioInputStream(fmt, in)); + int frames = b.length / (2 * ch); + if (frames < 64) throw new Exception("file is too short"); + // average down to at most ~22 kHz and at most 8 million samples + int k = max(1, round(src.getSampleRate() / 22050)); + while (frames / k > 8000000) k++; + int n = frames / k; + float[] s = new float[n]; + for (int i = 0; i < n; i++) { + float sum = 0; + for (int a = 0; a < k; a++) for (int c = 0; c < ch; c++) { + int o = ((i * k + a) * ch + c) * 2; + sum += (short) ((b[o] & 255) | (b[o + 1] << 8)) / 32768.0; + } + s[i] = sum / (k * ch); + } + audioSamples = s; + audioRate = max(1, round(src.getSampleRate() / k)); + audioName = f.getName(); audioPath = f.getAbsolutePath(); + sourceChanged(33); + setStatus(String.format(Locale.US, "Audio loaded: %s (%.1f s)", f.getName(), n / (float) audioRate)); + return true; + } catch (Exception e) { + setStatus("Could not read audio " + f.getName() + ": " + errText(e) + " (use WAV, AIFF or AU)"); + return false; + } +} + +void genAudio(ArrayList> out) { + int style = (int) pv(0), lines = (int) pv(1), res = (int) pv(3), bands = (int) pv(4); + float amp = pv(2), gain = pv(5); + boolean hide = pv(6) > 0.5; + float[] s = audioSamples != null ? audioSamples : demoAudio(); + int rate = audioSamples != null ? audioRate : 22050; + + if (style == 2) { // spectrum ridgeline + float[][] v = new float[lines][]; + float mx = 1e-9; + for (int j = 0; j < lines; j++) { + float[] b = spectrumBands(s, rate, (int) ((long) s.length * j / lines), (int) ((long) s.length * (j + 1) / lines), bands); + for (float x : b) mx = max(mx, x); + v[j] = b; + } + for (int j = 0; j < lines; j++) { + for (int i = 0; i < v[j].length; i++) v[j][i] = min(1, v[j][i] / mx * gain); + v[j] = resample(v[j], res + 1); + } + ridgelines(out, v, amp, hide); + return; + } + + // peak envelope in res buckets per row (or per turn) + float peak = 1e-9; + for (float x : s) peak = max(peak, abs(x)); + int buckets = lines * res; + float[] env = new float[buckets]; + for (int i = 0; i < buckets; i++) { + int a = (int) ((long) s.length * i / buckets), b = max(a + 1, (int) ((long) s.length * (i + 1) / buckets)); + float m = 0; + for (int k = a; k < min(b, s.length); k++) m = max(m, abs(s[k])); + env[i] = min(1, m / peak * gain); + } + if (style == 0) { // rows of zig-zag waveform + float w = xmax - xmin, top = ymin + amp, bot = ymax - amp; + if (top > bot) top = bot = (ymin + ymax) / 2; + for (int j = 0; j < lines; j++) { + float base = lines == 1 ? (top + bot) / 2 : lerp(top, bot, j / (float) (lines - 1)); + ArrayList pts = new ArrayList(res); + for (int i = 0; i < res; i++) pts.add(new PVector(xmin + w * i / max(1, res - 1), base + ((i & 1) == 0 ? -1 : 1) * env[j * res + i] * amp)); + emit(out, pts); + } + } else { // one spiral, the waveform zig-zags across it + float cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, R = min(xmax - xmin, ymax - ymin) / 2 - amp; + float r0 = R * 0.08, step = (R - r0) / lines; + ArrayList pts = new ArrayList(buckets); + for (int i = 0; i < buckets; i++) { + float t = i / (float) res, a = -HALF_PI + TWO_PI * t, r = r0 + step * t + ((i & 1) == 0 ? -1 : 1) * env[i] * min(amp, step * 0.48); + pts.add(new PVector(cx + cos(a) * r, cy + sin(a) * r)); + } + emit(out, pts); + } +} + +// Mean log-magnitude in 'bands' log-spaced bands (40 Hz .. 16 kHz) over the +// samples a..b, from up to four Hann-windowed 2048-point FFTs. +float[] spectrumBands(float[] s, int rate, int a, int b, int bands) { + int N = 2048; + float[] mag = new float[N / 2]; + int frames = 0; + for (int f = 0; f < 4; f++) { + int start = a + (int) ((b - a - N) * (f + 0.5) / 4.0); + if (b - a < N) start = (a + b) / 2 - N / 2; + double[] re = new double[N], im = new double[N]; + for (int i = 0; i < N; i++) { + int k = start + i; + double w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1)); + re[i] = k >= 0 && k < s.length ? s[k] * w : 0; + } + fft(re, im); + for (int i = 0; i < N / 2; i++) mag[i] += (float) Math.sqrt(re[i] * re[i] + im[i] * im[i]); + frames++; + if (b - a < N) break; + } + float lo = 40, hi = min(16000, rate / 2.0); + float[] out = new float[bands]; + for (int k = 0; k < bands; k++) { + float f0 = lo * pow(hi / lo, k / (float) bands), f1 = lo * pow(hi / lo, (k + 1) / (float) bands); + int i0 = constrain(floor(f0 * N / rate), 1, N / 2 - 1), i1 = constrain(ceil(f1 * N / rate), i0 + 1, N / 2); + float sum = 0; + for (int i = i0; i < i1; i++) sum += mag[i]; + out[k] = log(1 + sum / (i1 - i0) / frames); + } + return out; +} + +// In-place iterative radix-2 FFT (length must be a power of two). +void fft(double[] re, double[] im) { + int n = re.length; + for (int i = 1, j = 0; i < n; i++) { + int bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { double t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; } + } + for (int len = 2; len <= n; len <<= 1) { + double ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang); + for (int i = 0; i < n; i += len) { + double cr = 1, ci = 0; + for (int k = 0; k < len / 2; k++) { + int p = i + k, q = p + len / 2; + double xr = re[q] * cr - im[q] * ci, xi = re[q] * ci + im[q] * cr; + re[q] = re[p] - xr; im[q] = im[p] - xi; + re[p] += xr; im[p] += xi; + double t = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = t; + } + } + } +} + +float[] demoSound; + +// Six seconds of synthetic music: a rising chirp, kick drums and a chord. +float[] demoAudio() { + if (demoSound != null) return demoSound; + int rate = 22050, n = rate * 6; + float[] s = new float[n]; + for (int i = 0; i < n; i++) { + double t = i / (double) rate; + double chirp = 0.3 * Math.sin(2 * Math.PI * (200 * t + 150 * t * t)) * (0.5 + 0.5 * Math.sin(t * 1.3)); + double bt = t % 0.5, kick = 0.8 * Math.exp(-bt * 18) * Math.sin(2 * Math.PI * 60 * bt * (1 + 2 * Math.exp(-bt * 30))); + double chord = 0.12 * (Math.sin(2 * Math.PI * 440 * t) + Math.sin(2 * Math.PI * 554.4 * t) + Math.sin(2 * Math.PI * 659.3 * t)) * Math.max(0, Math.sin(Math.PI * t / 3)); + s[i] = (float) Math.max(-1, Math.min(1, chirp + kick + chord)); + } + demoSound = s; + return s; +} diff --git a/GenerativeLineArt/Cli.pde b/GenerativeLineArt/Cli.pde new file mode 100644 index 0000000..980d923 --- /dev/null +++ b/GenerativeLineArt/Cli.pde @@ -0,0 +1,150 @@ +// Command-line mode: build a design from presets and sources without the GUI +// and write the export, e.g. from a script, a scheduled task or a flow. +// +// Processing cli --sketch=GenerativeLineArt --run --preset presets/x.json \ +// --input data.csv --out exports/x.svg +// +// (An exported application takes the same arguments.) Without --watch the +// window stays hidden and the sketch quits after exporting; the exit code +// is 1 when something failed. Run with --help for every option. + +boolean cliMode = false, cliOneShot = false; +String cliOut = null; +int cliBatch = 0, cliExitCode = 0; + +class CliAbort extends RuntimeException { CliAbort(String m) { super(m); } } + +final String CLI_HELP = String.join("\n", + "PlotterGraph command-line options:", + " --preset load a preset (.json)", + " --input load a source by its type (image, csv, json, gpx, geojson, svg, wav, txt, asc);", + " may be repeated. A layer switches to the matching pattern if none uses it yet", + " --grid load a field grid (csv matrix, .asc, heightmap image)", + " --layer <1-4> the layer that --input, --text and --seed act on (default 1)", + " --location for --streets, --terrain and --weather", + " --streets [all|major|water|buildings] --radius --terrain [zoom] --weather", + " --text text for the Text pattern ( | = new line)", + " --seed seed of the layer; --seed-text seed from text; --design-text design from text", + " --prompt AI design (needs ANTHROPIC_API_KEY)", + " --paper A5|A4|A3|Square --landscape --portrait", + " --format svg|gcode|hpgl --out (default exports/lineart_