23 Commits

Author SHA1 Message Date
78c7b32eb2 cronr: recurring/one-shot jobs as systemd user timers
add (schedule validated by systemd-analyze, units + script printed),
list (next elapse + last result), run, logs, rm. Command stored as a
script so quoting never meets ExecStart; PATH includes ~/.local/bin
so agent tools resolve. cronr-* namespace guards foreign units.
Smoked live: add -> run -> journal -> rm. Spec: doc/tool-parity.md 3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:42:57 +02:00
c676dc270a Merge dev/waitfor: waitfor v1 2026-08-07 00:39:58 +02:00
6c8cae885b waitfor: block until a shell condition holds
--cmd exits 0 (+ optional --matches regex) or --timeout; exit 0/3/1,
last output always printed, --then hook composes with notifyr.
Injectable runner for tests + real-shell integration test.
Spec: doc/tool-parity.md 3.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:39:58 +02:00
2c34b66da5 Merge dev/svg-maker: svgc v1 2026-08-07 00:37:51 +02:00
36631c7709 svg-maker: svgc - .svgd text format -> SVG for agent-helm sharing
Line-based DSL (shapes, text, groups, color vars) with strict
validation and line-numbered errors, SVG emitter with visibility
defaults, truecolor terminal preview (same half-block technique as
spritec), info with bbox + outside-canvas warnings. Verified
end-to-end: svgc build -> helmd share -> visible through the hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:37:51 +02:00
9f90d4b8be Merge dev/notifyr: notifyr v1 2026-08-07 00:30:12 +02:00
9339aac5cf notifyr: ntfy send/read client for agents
send (title/priority/tags), read (poll mode, greppable one-liners),
topics (known homelab buses from config). Config with homelab defaults
on first run. Unit tests against httptest; smoked against the real
ntfy on topic agent-tools-test. Spec: doc/tool-parity.md 3.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:30:12 +02:00
e5b1d73c70 doc(tool-parity): live test of agy 1.1.9 — real toolset, schedule limits, buy-before-build notes
All checks were successful
release-tools / build-release (push) Successful in 5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:47:58 +02:00
5131e9f823 doc: tool-parity plan — Gemini CLI vs Claude Code gap analysis + homelab admin tool specs
All checks were successful
release-tools / build-release (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:27:58 +02:00
c2e89eeaa1 ci/tasks/readme: register hitbox-tool
All checks were successful
release-tools / build-release (push) Successful in 36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:12:39 +02:00
0ebaf7b4ce Merge dev/hitbox-tool: hitbox v1 2026-07-14 07:12:05 +02:00
f8924d89f7 hitbox-tool: per-frame collision boxes from sprite sheet alpha -> JSON
- parses the spritec _WxH_CxR naming convention incl. auto-detection of
  integer-upscaled sheets (256x64 named 8x8_4x1 -> 64x64 cells)
- tight alpha bbox per frame with threshold/shrink/pad tuning, row-major
  indices, empty-frame flags; boxes relative to frame origin
- show command draws frames + box outline in the terminal for verification
- go tests: scanning, threshold, shrink/pad clamping, name parsing,
  scale inference, JSON roundtrip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:12:05 +02:00
dc78c791df ci/tasks/readme: register sfx-maker (sfxc)
All checks were successful
release-tools / build-release (push) Successful in 32s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:08:41 +02:00
d2bf781a71 Merge dev/sfx-maker: sfxc v1 2026-07-14 07:08:05 +02:00
e7ac31b5c8 sfx-maker: sfxc - sfxr-style .sfx text presets -> 16-bit WAV synthesis
- waves: square (duty), saw, sine, triangle, pitched noise (seeded, deterministic)
- envelope attack/sustain/decay, freq slide, vibrato, arpeggio jump,
  one-pole low/high-pass filters, clamped output
- presets blip/coin/explosion/hurt/jump/laser/powerup with seeded variants;
  preset writes editable .sfx or renders .wav directly
- go tests: parse/validate, determinism, per-preset RMS, WAV header roundtrip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:08:04 +02:00
ec814c373a ci/tasks/readme: register bitmap-font-maker (fontc)
All checks were successful
release-tools / build-release (push) Successful in 36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:04:28 +02:00
6b3a493703 Merge dev/bitmap-font-maker: fontc v1 2026-07-14 07:03:15 +02:00
6b10c4a57c bitmap-font-maker: fontc - .font glyph grids -> atlas PNG + metrics JSON + text rendering
- .font format: glyph sections with #/. grids, proportional widths,
  literal unicode glyph names, spacing/space-width/line-height/baseline
- build (atlas white-on-transparent + JSON metrics), render (text -> PNG
  with \n, scale, color), info, preview (terminal half-blocks)
- example tiny5 font: A-Z, ÅÄÖ, 0-9, punctuation (47 glyphs, 3x5)
- go tests: parsing, errors, atlas metrics, text rendering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:03:04 +02:00
a5b8a249ed Merge dev/mesh-tool: mesht v1
All checks were successful
release-tools / build-release (push) Successful in 1m53s
2026-07-14 02:39:01 +02:00
2e71a70052 mesh-tool: edited example models (teapot/cow/snowman) made with mesht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 02:39:01 +02:00
62065f237b mesh-tool: mesht - create/inspect/edit OBJ+STL with ASCII multi-view rendering
- OBJ read/write (multi-object, ngon fan-triangulation, negative indices),
  STL binary+ascii with auto-detect and vertex welding
- info: bbox/size/area/volume + watertightness via edge manifold stats
- view: z-buffered orthographic ASCII renders (front/side/top/iso/back)
- transform: center/mirror/scale/rotate/translate/fit, per-object filter,
  winding auto-flip on negative determinant
- create: box/sphere/cylinder/cone/plane/torus; merge; convert
- go tests: primitive volumes vs analytic values, roundtrips, topology

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 02:38:29 +02:00
3eb4fa0b1d Merge dev/pixel-sprite-maker: spritec v1 2026-07-14 02:37:58 +02:00
42422be2f7 scaffolding: root README, plan, VS Code build tasks, Gitea Actions release workflow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 02:37:58 +02:00
88 changed files with 41859 additions and 1 deletions

View File

@@ -0,0 +1,111 @@
name: release-tools
# På varje push till master/main: bygg de tools som fått ändringar
# (linux x64 + arm64) och lägg binärerna på en rullande
# "<tool>-latest"-release på släppsidan i Gitea.
on:
push:
branches: [master, main]
workflow_dispatch: {} # manuell körning bygger ALLA tools
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # hela historiken behövs för diffen mot 'before'
- name: Detektera ändrade tools
id: changed
run: |
TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool"
BEFORE="${{ github.event.before }}"
CHANGED=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
|| [ -z "$BEFORE" ] \
|| echo "$BEFORE" | grep -Eq '^0+$' \
|| ! git cat-file -e "$BEFORE" 2>/dev/null; then
echo "första push / manuell körning -> bygger alla tools"
CHANGED="$TOOLS"
else
for t in $TOOLS; do
if ! git diff --quiet "$BEFORE" "${{ github.sha }}" -- "$t/"; then
CHANGED="$CHANGED $t"
fi
done
fi
CHANGED="$(echo $CHANGED)" # trimma whitespace
echo "tools=$CHANGED" >> "$GITHUB_OUTPUT"
echo "bygger: ${CHANGED:-inget}"
- name: Installera Go
if: steps.changed.outputs.tools != ''
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: false
- name: Testa + bygg (x64 + arm64)
if: steps.changed.outputs.tools != ''
run: |
set -e
VERSION="latest-$(git rev-parse --short HEAD)"
for t in ${{ steps.changed.outputs.tools }}; do
case "$t" in
pixel-sprite-maker) BIN=spritec ;;
mesh-tool) BIN=mesht ;;
bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;;
*) echo "okänt tool $t"; exit 1 ;;
esac
echo "=== $t ($BIN) ==="
cd "$t"
go test ./...
mkdir -p build
GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o "build/$BIN-linux-x64" .
GOOS=linux GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o "build/$BIN-linux-arm64" .
(cd build && sha256sum "$BIN"-linux-* > checksums.txt && ls -la)
cd ..
done
- name: Skapa/uppdatera releaser + ladda upp binärer
if: steps.changed.outputs.tools != ''
env:
API: http://gitea-d:3000/api/v1
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
for t in ${{ steps.changed.outputs.tools }}; do
case "$t" in
pixel-sprite-maker) BIN=spritec ;;
mesh-tool) BIN=mesht ;;
bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;;
esac
TAG="$t-latest"
BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."
rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
[ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
echo "$t -> release id $rid"
for f in "$BIN-linux-x64" "$BIN-linux-arm64" checksums.txt; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \
-H "Authorization: token $TOKEN" -o /dev/null
fi
curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$t/build/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n"
done
done

92
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,92 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build pixel-sprite-maker",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/spritec .",
"options": { "cwd": "${workspaceFolder}/pixel-sprite-maker" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "build mesh-tool",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/mesht .",
"options": { "cwd": "${workspaceFolder}/mesh-tool" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "build bitmap-font-maker",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/fontc .",
"options": { "cwd": "${workspaceFolder}/bitmap-font-maker" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "test bitmap-font-maker",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/bitmap-font-maker" },
"group": "test",
"problemMatcher": ["$go"]
},
{
"label": "build sfx-maker",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/sfxc .",
"options": { "cwd": "${workspaceFolder}/sfx-maker" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "test sfx-maker",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/sfx-maker" },
"group": "test",
"problemMatcher": ["$go"]
},
{
"label": "build hitbox-tool",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/hitbox .",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "test hitbox-tool",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" },
"group": "test",
"problemMatcher": ["$go"]
},
{
"label": "test pixel-sprite-maker",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/pixel-sprite-maker" },
"group": "test",
"problemMatcher": ["$go"]
},
{
"label": "test mesh-tool",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/mesh-tool" },
"group": "test",
"problemMatcher": ["$go"]
},
{
"label": "build",
"dependsOn": ["build pixel-sprite-maker", "build mesh-tool", "build bitmap-font-maker", "build sfx-maker", "build hitbox-tool"],
"dependsOrder": "parallel",
"group": { "kind": "build", "isDefault": true },
"problemMatcher": []
}
]
}

View File

@@ -1,3 +1,58 @@
# agent-tools
smal tools for ai agents
Small, self-contained CLI tools built for **AI agents** to use during
development work. Every tool is a single static Go binary with a
text-first interface: input formats an agent can read and write
directly, and output (names, errors, previews) explicit enough that the
agent understands the result without opening an image viewer.
| Tool | Binary | What it does |
|------|--------|--------------|
| [`pixel-sprite-maker/`](pixel-sprite-maker/) | `spritec` | Turns `.sprite` text files (palette + character grid) into PNG/JPG/SVG pixel art, up to 256x256 px per sprite. Combines several sprites into sprite sheets / animation strips whose **file names document the layout** (`walk_8x8_4x1.png` = 8x8 px frames, 4 columns, 1 row). |
| [`mesh-tool/`](mesh-tool/) | `mesht` | Creates, inspects and edits 3D models (OBJ + STL). ASCII multi-view rendering + measurements (bbox, volume, watertightness) let an agent *see* a model, edit it (scale/rotate/mirror/merge/primitives) and verify the result. |
| [`bitmap-font-maker/`](bitmap-font-maker/) | `fontc` | Turns `.font` text files (pixel glyph grids, proportional widths) into font atlases (PNG + JSON metrics) and renders text strings to PNG or the terminal. |
| [`sfx-maker/`](sfx-maker/) | `sfxc` | Synthesizes retro game sound effects (sfxr-style) from `.sfx` text presets to 16-bit WAV: waves, envelope, pitch slides, vibrato, arpeggio, filters. Deterministic, with built-in presets (jump, coin, laser…). |
| [`hitbox-tool/`](hitbox-tool/) | `hitbox` | Scans sprite sheet PNGs and writes per-frame collision boxes as JSON from the alpha channel. Understands the spritec sheet naming convention including upscaled sheets. |
Each tool has its own folder, its own README with the full format/CLI
reference, its own tests and its own dev branch (`dev/<tool>`).
## Planned: agent-capability & homelab-admin tools
[`doc/tool-parity.md`](doc/tool-parity.md) compares Gemini CLI's
built-in tools with Claude Code's, and specs the CLI tools that close
the gaps (`notifyr`, `giteactl`, `waitfor`, `cronr`, `fleet`,
`envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl`, `fanout`) so
any agent gets the same capabilities via `run_shell_command`. Build
order and rationale live there and in [`doc/plan.md`](doc/plan.md).
## Building
```bash
# Arch/Garuda prerequisite:
sudo pacman -S go
cd <tool>/ && go build -o build/<binary> . # per tool
go test ./... # per tool
```
VS Code: **Terminal → Run Build Task**`build` builds every tool into
its own `<tool>/build/` folder; per-tool `build <tool>` and
`test <tool>` tasks also exist.
## CI / releases
`.gitea/workflows/release.yml` runs on every push to `master`/`main` on
the self-hosted runner: tools whose folders changed are tested, built
for **linux x64 + arm64** (the Pi5), and published to a rolling
release per tool on the repo's release page:
- tag `pixel-sprite-maker-latest` → assets `spritec-linux-x64`, `spritec-linux-arm64`, `checksums.txt`
- tag `mesh-tool-latest` → assets `mesht-linux-x64`, `mesht-linux-arm64`, `checksums.txt`
A manual `workflow_dispatch` run builds all tools regardless of diffs.
## Branch layout
- `main` — stable, releases are built from here
- `dev/pixel-sprite-maker`, `dev/mesh-tool` — per-tool development branches

View File

@@ -0,0 +1,75 @@
# bitmap-font-maker (`fontc`)
Turns `.font` text files — pixel glyph grids an agent can read and edit
directly — into **font atlases (PNG + JSON metrics)** and renders text
strings to PNG. Proportional widths, unicode glyph names (ÅÄÖ works).
Go, zero dependencies, single static binary.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd bitmap-font-maker
go build -o build/fontc . # or the VS Code task "build bitmap-font-maker"
go test ./...
```
## The `.font` format
```
# comment
font: tiny5 optional name
spacing: 1 px between glyphs (default 1)
space-width: 3 advance of ' ' (default: width of '0')
line-height: 7 default: glyph height + 1
baseline: 5 default: glyph height
glyph A:
.#.
#.#
###
#.#
#.#
glyph :: the char between "glyph " and ":" is literal
.
#
.
#
.
```
- `#` = pixel on, `.` = off.
- **All glyphs share one height; widths may differ** (proportional fonts —
`M` can be 5 px wide while `.` is 1 px).
- Max glyph size 64x64.
## Commands
```bash
fontc build tiny5.font -o out/tiny5 # -> tiny5.png (atlas) + tiny5.json (metrics)
fontc render tiny5.font 'HEJ!\nRAD 2' --scale 4 --color '#FFD700' -o title.png
fontc info tiny5.font # validate + list glyphs
fontc preview tiny5.font 'HELLO' # draw in the terminal
```
## Atlas + metrics
The atlas draws glyphs **white on transparent** so game engines can tint
them. The JSON carries everything a loader needs:
```json
{
"name": "tiny5", "atlas": "tiny5.png",
"height": 5, "lineHeight": 6, "baseline": 5,
"spacing": 1, "spaceWidth": 3,
"glyphs": { "A": {"x": 0, "y": 0, "w": 3, "h": 5, "advance": 4}, ... }
}
```
Drawing text in a game: blit `glyphs[c]` from the atlas, advance the
cursor by `advance`; spaces advance `spaceWidth + spacing`; new lines
step `lineHeight`.
[`examples/tiny5.font`](examples/tiny5.font) is a complete 3x5 font:
A-Z, ÅÄÖ, 0-9 and punctuation.

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1,340 @@
{
"name": "tiny5",
"atlas": "tiny5.png",
"height": 5,
"lineHeight": 6,
"baseline": 5,
"spacing": 1,
"spaceWidth": 3,
"glyphs": {
"!": {
"x": 36,
"y": 30,
"w": 1,
"h": 5,
"advance": 2
},
"'": {
"x": 24,
"y": 36,
"w": 1,
"h": 5,
"advance": 2
},
"+": {
"x": 18,
"y": 36,
"w": 3,
"h": 5,
"advance": 4
},
",": {
"x": 30,
"y": 30,
"w": 2,
"h": 5,
"advance": 3
},
"-": {
"x": 12,
"y": 36,
"w": 3,
"h": 5,
"advance": 4
},
".": {
"x": 24,
"y": 30,
"w": 1,
"h": 5,
"advance": 2
},
"0": {
"x": 6,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"1": {
"x": 12,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"2": {
"x": 18,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"3": {
"x": 24,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"4": {
"x": 30,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"5": {
"x": 36,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
},
"6": {
"x": 0,
"y": 30,
"w": 3,
"h": 5,
"advance": 4
},
"7": {
"x": 6,
"y": 30,
"w": 3,
"h": 5,
"advance": 4
},
"8": {
"x": 12,
"y": 30,
"w": 3,
"h": 5,
"advance": 4
},
"9": {
"x": 18,
"y": 30,
"w": 3,
"h": 5,
"advance": 4
},
":": {
"x": 6,
"y": 36,
"w": 1,
"h": 5,
"advance": 2
},
"?": {
"x": 0,
"y": 36,
"w": 3,
"h": 5,
"advance": 4
},
"A": {
"x": 0,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"B": {
"x": 6,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"C": {
"x": 12,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"D": {
"x": 18,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"E": {
"x": 24,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"F": {
"x": 30,
"y": 0,
"w": 3,
"h": 5,
"advance": 4
},
"G": {
"x": 36,
"y": 0,
"w": 4,
"h": 5,
"advance": 5
},
"H": {
"x": 0,
"y": 6,
"w": 3,
"h": 5,
"advance": 4
},
"I": {
"x": 6,
"y": 6,
"w": 3,
"h": 5,
"advance": 4
},
"J": {
"x": 12,
"y": 6,
"w": 3,
"h": 5,
"advance": 4
},
"K": {
"x": 18,
"y": 6,
"w": 3,
"h": 5,
"advance": 4
},
"L": {
"x": 24,
"y": 6,
"w": 3,
"h": 5,
"advance": 4
},
"M": {
"x": 30,
"y": 6,
"w": 5,
"h": 5,
"advance": 6
},
"N": {
"x": 36,
"y": 6,
"w": 4,
"h": 5,
"advance": 5
},
"O": {
"x": 0,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"P": {
"x": 6,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"Q": {
"x": 12,
"y": 12,
"w": 4,
"h": 5,
"advance": 5
},
"R": {
"x": 18,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"S": {
"x": 24,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"T": {
"x": 30,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"U": {
"x": 36,
"y": 12,
"w": 3,
"h": 5,
"advance": 4
},
"V": {
"x": 0,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"W": {
"x": 6,
"y": 18,
"w": 5,
"h": 5,
"advance": 6
},
"X": {
"x": 12,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"Y": {
"x": 18,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"Z": {
"x": 24,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"Ä": {
"x": 36,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"Å": {
"x": 30,
"y": 18,
"w": 3,
"h": 5,
"advance": 4
},
"Ö": {
"x": 0,
"y": 24,
"w": 3,
"h": 5,
"advance": 4
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

View File

@@ -0,0 +1,334 @@
# tiny5 - a 3x5 pixel font (uppercase + digits + Swedish ÅÄÖ).
# Widths vary: M/W are 5 px, punctuation as narrow as 1 px.
font: tiny5
spacing: 1
space-width: 3
glyph A:
.#.
#.#
###
#.#
#.#
glyph B:
##.
#.#
##.
#.#
##.
glyph C:
.##
#..
#..
#..
.##
glyph D:
##.
#.#
#.#
#.#
##.
glyph E:
###
#..
##.
#..
###
glyph F:
###
#..
##.
#..
#..
glyph G:
.###
#...
#.##
#..#
.##.
glyph H:
#.#
#.#
###
#.#
#.#
glyph I:
###
.#.
.#.
.#.
###
glyph J:
..#
..#
..#
#.#
.#.
glyph K:
#.#
#.#
##.
#.#
#.#
glyph L:
#..
#..
#..
#..
###
glyph M:
#...#
##.##
#.#.#
#...#
#...#
glyph N:
#..#
##.#
#.##
#..#
#..#
glyph O:
.#.
#.#
#.#
#.#
.#.
glyph P:
##.
#.#
##.
#..
#..
glyph Q:
.##.
#..#
#..#
#.#.
.#.#
glyph R:
##.
#.#
##.
#.#
#.#
glyph S:
.##
#..
.#.
..#
##.
glyph T:
###
.#.
.#.
.#.
.#.
glyph U:
#.#
#.#
#.#
#.#
###
glyph V:
#.#
#.#
#.#
#.#
.#.
glyph W:
#...#
#...#
#.#.#
##.##
#...#
glyph X:
#.#
#.#
.#.
#.#
#.#
glyph Y:
#.#
#.#
.#.
.#.
.#.
glyph Z:
###
..#
.#.
#..
###
glyph Å:
.#.
.#.
#.#
###
#.#
glyph Ä:
#.#
.#.
#.#
###
#.#
glyph Ö:
#.#
.#.
#.#
#.#
.#.
glyph 0:
###
#.#
#.#
#.#
###
glyph 1:
.#.
##.
.#.
.#.
###
glyph 2:
##.
..#
.#.
#..
###
glyph 3:
###
..#
.##
..#
###
glyph 4:
#.#
#.#
###
..#
..#
glyph 5:
###
#..
##.
..#
##.
glyph 6:
.##
#..
###
#.#
###
glyph 7:
###
..#
.#.
.#.
.#.
glyph 8:
###
#.#
###
#.#
###
glyph 9:
###
#.#
###
..#
##.
glyph .:
.
.
.
.
#
glyph ,:
..
..
..
.#
#.
glyph !:
#
#
#
.
#
glyph ?:
###
..#
.#.
...
.#.
glyph ::
.
#
.
#
.
glyph -:
...
...
###
...
...
glyph +:
...
.#.
###
.#.
...
glyph ':
#
#
.
.
.

View File

@@ -0,0 +1,238 @@
// Package font parses .font text files and renders bitmap fonts to
// atlases (PNG + JSON metrics) and text images.
package font
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// MaxGlyphSize bounds glyph width/height in pixels.
const MaxGlyphSize = 64
// Glyph is one character's bitmap: rows of booleans (true = pixel on).
// All glyphs in a font share the same height; widths vary
// (proportional fonts).
type Glyph struct {
Char rune
W, H int
Rows [][]bool
}
// Font is a parsed .font file.
type Font struct {
Name string
Height int // glyph height, uniform across the font
LineHeight int // suggested distance between text baselines
Baseline int // rows from glyph top to the baseline
Spacing int // horizontal px between glyphs
SpaceWidth int // advance of ' '
Glyphs map[rune]*Glyph
Order []rune // file order, for stable atlas layout
}
// ParseFile reads a .font file; the font name defaults to the file name.
func ParseFile(path string) (*Font, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
ft, err := Parse(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if ft.Name == "" {
ft.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
return ft, nil
}
// Parse reads the .font text format:
//
// # comment
// font: tiny5 optional name
// spacing: 1 px between glyphs (default 1)
// space-width: 3 advance of ' ' (default: width of '0' or 3)
// line-height: 7 default: glyph height + 1
// baseline: 5 default: glyph height
//
// glyph A:
// .#.
// #.#
// ###
// #.#
// #.#
//
// Glyph grids use '#' for on and '.' for off. Every glyph must have the
// same height; widths may differ. The char between "glyph " and the
// trailing ':' is taken literally (one character, e.g. "glyph ::").
func Parse(r io.Reader) (*Font, error) {
ft := &Font{
Spacing: 1,
SpaceWidth: -1, // resolved in validate
LineHeight: -1,
Baseline: -1,
Glyphs: map[rune]*Glyph{},
}
var cur *Glyph
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNo := 0
flush := func() error {
if cur == nil {
return nil
}
if len(cur.Rows) == 0 {
return fmt.Errorf("glyph %q has no grid rows", string(cur.Char))
}
cur.H = len(cur.Rows)
cur.W = len(cur.Rows[0])
for i, row := range cur.Rows {
if len(row) != cur.W {
return fmt.Errorf("glyph %q row %d is %d px wide, expected %d", string(cur.Char), i+1, len(row), cur.W)
}
}
if cur.W > MaxGlyphSize || cur.H > MaxGlyphSize {
return fmt.Errorf("glyph %q is %dx%d; the maximum is %dx%d", string(cur.Char), cur.W, cur.H, MaxGlyphSize, MaxGlyphSize)
}
if _, dup := ft.Glyphs[cur.Char]; dup {
return fmt.Errorf("glyph %q defined twice", string(cur.Char))
}
ft.Glyphs[cur.Char] = cur
ft.Order = append(ft.Order, cur.Char)
cur = nil
return nil
}
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
// '#' starts a comment — except inside a glyph where a line of
// only '#'/'.' is a grid row.
if strings.HasPrefix(line, "#") && !(cur != nil && isGridLine(line)) {
continue
}
if strings.HasPrefix(strings.ToLower(line), "glyph ") && strings.HasSuffix(line, ":") {
if err := flush(); err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
name := strings.TrimSuffix(line[len("glyph "):], ":")
runes := []rune(name)
if len(runes) != 1 {
return nil, fmt.Errorf("line %d: glyph name %q must be exactly one character", lineNo, name)
}
cur = &Glyph{Char: runes[0]}
continue
}
if cur != nil && isGridLine(line) {
row := make([]bool, 0, len(line))
for _, r := range line {
row = append(row, r == '#')
}
cur.Rows = append(cur.Rows, row)
continue
}
// header key: value
if i := strings.Index(line, ":"); i > 0 && cur == nil {
key := strings.ToLower(strings.TrimSpace(line[:i]))
val := strings.TrimSpace(line[i+1:])
var err error
switch key {
case "font":
ft.Name = val
case "spacing":
ft.Spacing, err = strconv.Atoi(val)
case "space-width":
ft.SpaceWidth, err = strconv.Atoi(val)
case "line-height":
ft.LineHeight, err = strconv.Atoi(val)
case "baseline":
ft.Baseline, err = strconv.Atoi(val)
default:
return nil, fmt.Errorf("line %d: unknown setting %q", lineNo, key)
}
if err != nil {
return nil, fmt.Errorf("line %d: %s: %v", lineNo, key, err)
}
continue
}
return nil, fmt.Errorf("line %d: unexpected %q (want 'key: value', 'glyph X:' or a #/. grid row)", lineNo, line)
}
if err := sc.Err(); err != nil {
return nil, err
}
if err := flush(); err != nil {
return nil, err
}
return ft, ft.validate()
}
// isGridLine reports whether the line consists solely of '#' and '.'.
func isGridLine(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r != '#' && r != '.' {
return false
}
}
return true
}
func (ft *Font) validate() error {
if len(ft.Order) == 0 {
return fmt.Errorf("font has no glyphs")
}
ft.Height = ft.Glyphs[ft.Order[0]].H
for _, r := range ft.Order {
if g := ft.Glyphs[r]; g.H != ft.Height {
return fmt.Errorf("glyph %q is %d px tall but %q is %d — all glyphs must share one height",
string(r), g.H, string(ft.Order[0]), ft.Height)
}
}
if ft.LineHeight < 0 {
ft.LineHeight = ft.Height + 1
}
if ft.Baseline < 0 {
ft.Baseline = ft.Height
}
if ft.SpaceWidth < 0 {
if g, ok := ft.Glyphs['0']; ok {
ft.SpaceWidth = g.W
} else {
ft.SpaceWidth = 3
}
}
if ft.Spacing < 0 {
return fmt.Errorf("spacing must be >= 0")
}
return nil
}
// MaxGlyphWidth returns the widest glyph's width.
func (ft *Font) MaxGlyphWidth() int {
w := 0
for _, r := range ft.Order {
if g := ft.Glyphs[r]; g.W > w {
w = g.W
}
}
return w
}
// SortedChars returns the glyph chars sorted by codepoint (JSON stability).
func (ft *Font) SortedChars() []rune {
out := append([]rune(nil), ft.Order...)
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}

View File

@@ -0,0 +1,144 @@
package font
import (
"bytes"
"encoding/json"
"image/color"
"strings"
"testing"
)
const tiny = `
font: t
spacing: 1
space-width: 2
glyph A:
.#.
#.#
###
glyph I:
#
#
#
`
func parseOK(t *testing.T, src string) *Font {
t.Helper()
ft, err := Parse(strings.NewReader(src))
if err != nil {
t.Fatalf("parse: %v", err)
}
return ft
}
func TestParseBasics(t *testing.T) {
ft := parseOK(t, tiny)
if ft.Name != "t" || ft.Height != 3 || len(ft.Order) != 2 {
t.Errorf("name=%q height=%d glyphs=%d", ft.Name, ft.Height, len(ft.Order))
}
a := ft.Glyphs['A']
if a.W != 3 || a.H != 3 {
t.Errorf("A is %dx%d, want 3x3", a.W, a.H)
}
if !a.Rows[1][0] || a.Rows[0][0] {
t.Error("A pixel pattern wrong")
}
i := ft.Glyphs['I']
if i.W != 1 {
t.Errorf("I width = %d, want 1 (proportional)", i.W)
}
if ft.LineHeight != 4 || ft.Baseline != 3 {
t.Errorf("defaults: lineHeight=%d baseline=%d", ft.LineHeight, ft.Baseline)
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"no glyphs": "font: x\n",
"ragged glyph": "glyph A:\n##\n#\n",
"height differs": "glyph A:\n#\n#\n\nglyph B:\n#\n",
"dup glyph": "glyph A:\n#\n\nglyph A:\n#\n",
"bad name": "glyph AB:\n#\n",
"unknown key": "wat: 3\nglyph A:\n#\n",
}
for name, src := range cases {
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestGlyphNameColon(t *testing.T) {
ft := parseOK(t, "glyph ::\n#\n#\n")
if _, ok := ft.Glyphs[':']; !ok {
t.Error("glyph ':' not parsed")
}
}
func TestAtlasAndMetrics(t *testing.T) {
ft := parseOK(t, tiny)
img, m := ft.BuildAtlas("t.png")
if m.Glyphs["A"].Advance != 4 {
t.Errorf("A advance = %d, want 4 (w3 + spacing1)", m.Glyphs["A"].Advance)
}
g := m.Glyphs["A"]
// center-top pixel of A within its atlas cell: (x+1, y+0)
if img.NRGBAAt(g.X+1, g.Y).A == 0 {
t.Error("A apex pixel missing in atlas")
}
if img.NRGBAAt(g.X, g.Y).A != 0 {
t.Error("A corner should be empty")
}
var buf bytes.Buffer
if err := WriteMetrics(&buf, m); err != nil {
t.Fatal(err)
}
var back Metrics
if err := json.Unmarshal(buf.Bytes(), &back); err != nil {
t.Fatalf("metrics json invalid: %v", err)
}
if back.Glyphs["I"].W != 1 {
t.Errorf("metrics roundtrip: I.w = %d", back.Glyphs["I"].W)
}
}
func TestRenderText(t *testing.T) {
ft := parseOK(t, tiny)
white := color.NRGBA{255, 255, 255, 255}
img, warns := ft.RenderText("AI", 1, white)
if len(warns) != 0 {
t.Errorf("warnings: %v", warns)
}
// width: A(3) + spacing(1) + I(1) = 5
if b := img.Bounds(); b.Dx() != 5 || b.Dy() != 3 {
t.Errorf("size = %dx%d, want 5x3", b.Dx(), b.Dy())
}
// I column at x=4
if img.NRGBAAt(4, 0).A == 0 {
t.Error("I pixel missing")
}
_, warns = ft.RenderText("AXA", 1, white)
if len(warns) != 1 {
t.Errorf("missing-glyph warning expected, got %v", warns)
}
img, _ = ft.RenderText(`A\nA`, 1, white)
if b := img.Bounds(); b.Dy() != ft.LineHeight+ft.Height {
t.Errorf("two-line height = %d, want %d", b.Dy(), ft.LineHeight+ft.Height)
}
img2, _ := ft.RenderText("A", 3, white)
if b := img2.Bounds(); b.Dx() != 9 || b.Dy() != 9 {
t.Errorf("scaled size = %dx%d, want 9x9", b.Dx(), b.Dy())
}
}
func TestSpaceWidthDefaultFromZero(t *testing.T) {
ft := parseOK(t, "glyph 0:\n####\n####\n")
if ft.SpaceWidth != 4 {
t.Errorf("space-width = %d, want 4 (width of '0')", ft.SpaceWidth)
}
}

View File

@@ -0,0 +1,165 @@
package font
import (
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
)
// AtlasGlyph is one glyph's placement in the atlas.
type AtlasGlyph struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
Advance int `json:"advance"` // cursor movement after drawing: W + spacing
}
// Metrics is the JSON sidecar written next to the atlas PNG.
type Metrics struct {
Name string `json:"name"`
Atlas string `json:"atlas"`
Height int `json:"height"`
LineHeight int `json:"lineHeight"`
Baseline int `json:"baseline"`
Spacing int `json:"spacing"`
SpaceWidth int `json:"spaceWidth"`
Glyphs map[string]AtlasGlyph `json:"glyphs"`
}
// BuildAtlas packs all glyphs into a grid atlas image (white pixels on
// transparency, so game engines can tint) and returns the metrics.
// atlasName is stored in the metrics so loaders can find the image.
func (ft *Font) BuildAtlas(atlasName string) (*image.NRGBA, *Metrics) {
n := len(ft.Order)
cols := 1
for cols*cols < n {
cols++
}
rows := (n + cols - 1) / cols
cellW := ft.MaxGlyphWidth() + 1 // 1 px padding against bleed
cellH := ft.Height + 1
img := image.NewNRGBA(image.Rect(0, 0, cols*cellW, rows*cellH))
white := color.NRGBA{255, 255, 255, 255}
m := &Metrics{
Name: ft.Name,
Atlas: atlasName,
Height: ft.Height,
LineHeight: ft.LineHeight,
Baseline: ft.Baseline,
Spacing: ft.Spacing,
SpaceWidth: ft.SpaceWidth,
Glyphs: map[string]AtlasGlyph{},
}
for i, r := range ft.Order {
g := ft.Glyphs[r]
x0 := (i % cols) * cellW
y0 := (i / cols) * cellH
drawGlyph(img, g, x0, y0, white)
m.Glyphs[string(r)] = AtlasGlyph{X: x0, Y: y0, W: g.W, H: g.H, Advance: g.W + ft.Spacing}
}
return img, m
}
func drawGlyph(img *image.NRGBA, g *Glyph, x0, y0 int, c color.NRGBA) {
for y, row := range g.Rows {
for x, on := range row {
if on {
img.SetNRGBA(x0+x, y0+y, c)
}
}
}
}
// RenderText draws text into a new image using the font. scale is an
// integer upscale factor; unknown characters are skipped with a warning
// returned. "\n" (literal backslash-n) and real newlines both break lines.
func (ft *Font) RenderText(text string, scale int, fg color.NRGBA) (*image.NRGBA, []string) {
if scale < 1 {
scale = 1
}
text = strings.ReplaceAll(text, `\n`, "\n")
lines := strings.Split(text, "\n")
var warnings []string
width := 0
for _, line := range lines {
if w := ft.lineWidth(line); w > width {
width = w
}
}
if width == 0 {
width = 1
}
height := ft.LineHeight*(len(lines)-1) + ft.Height
small := image.NewNRGBA(image.Rect(0, 0, width, height))
for li, line := range lines {
x := 0
y := li * ft.LineHeight
for _, r := range line {
if r == ' ' {
x += ft.SpaceWidth + ft.Spacing
continue
}
g, ok := ft.Glyphs[r]
if !ok {
warnings = append(warnings, fmt.Sprintf("no glyph for %q — skipped", string(r)))
x += ft.SpaceWidth + ft.Spacing
continue
}
drawGlyph(small, g, x, y, fg)
x += g.W + ft.Spacing
}
}
if scale == 1 {
return small, warnings
}
b := small.Bounds()
big := image.NewNRGBA(image.Rect(0, 0, b.Dx()*scale, b.Dy()*scale))
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
c := small.NRGBAAt(x, y)
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
big.SetNRGBA(x*scale+dx, y*scale+dy, c)
}
}
}
}
return big, warnings
}
func (ft *Font) lineWidth(line string) int {
x := 0
for _, r := range line {
if r == ' ' {
x += ft.SpaceWidth + ft.Spacing
continue
}
if g, ok := ft.Glyphs[r]; ok {
x += g.W + ft.Spacing
} else {
x += ft.SpaceWidth + ft.Spacing
}
}
if x > 0 {
x -= ft.Spacing // no trailing spacing
}
return x
}
// WritePNG encodes an image as PNG.
func WritePNG(w io.Writer, img image.Image) error { return png.Encode(w, img) }
// WriteMetrics encodes metrics as indented JSON.
func WriteMetrics(w io.Writer, m *Metrics) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(m)
}

3
bitmap-font-maker/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/bitmap-font-maker
go 1.24

258
bitmap-font-maker/main.go Normal file
View File

@@ -0,0 +1,258 @@
// fontc turns .font text files (pixel glyph grids) into font atlases
// (PNG + JSON metrics) and renders text strings to images.
package main
import (
"flag"
"fmt"
"image/color"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/bitmap-font-maker/font"
)
var version = "dev"
const usage = `fontc - bitmap font maker for agents
Usage:
fontc build <file.font> [flags] build atlas PNG + metrics JSON
fontc render <file.font> <text> [flags]
render a text string to PNG
fontc info <file.font> validate + list glyphs
fontc preview <file.font> <text> draw text in the terminal
fontc version
Build flags:
-o <base> output base name -> <base>.png + <base>.json
(default: font file name without extension)
Render flags:
-o <path> output PNG (default text.png)
--scale <n> integer upscale, default 1
--color <c> text color (#RRGGBB, CSS names not supported here), default #FFFFFF
Use \n in <text> for line breaks.
The .font format:
font: tiny5 optional name
spacing: 1 px between glyphs (default 1)
space-width: 3 advance of ' ' (default: width of '0')
line-height: 7 default: glyph height + 1
baseline: 5 default: glyph height
glyph A:
.#.
#.#
###
#.#
#.#
'#' = pixel on, '.' = off. All glyphs share one height; widths may
differ (proportional). The atlas draws glyphs in white so engines can
tint them; metrics JSON carries x/y/w/h/advance per glyph.
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "render":
cmdRender(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("fontc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'fontc help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output base name")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("build takes exactly one .font file")
}
ft, err := font.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
base := *out
if base == "" {
base = strings.TrimSuffix(fs.Arg(0), filepath.Ext(fs.Arg(0)))
}
base = strings.TrimSuffix(base, ".png")
img, metrics := ft.BuildAtlas(filepath.Base(base) + ".png")
pngF, err := os.Create(base + ".png")
if err != nil {
die("%v", err)
}
defer pngF.Close()
if err := font.WritePNG(pngF, img); err != nil {
die("%v", err)
}
jsonF, err := os.Create(base + ".json")
if err != nil {
die("%v", err)
}
defer jsonF.Close()
if err := font.WriteMetrics(jsonF, metrics); err != nil {
die("%v", err)
}
b := img.Bounds()
fmt.Printf("%s.png (%dx%d atlas, %d glyphs)\n%s.json\n", base, b.Dx(), b.Dy(), len(ft.Order), base)
}
func parseHexColor(s string) (r, g, b uint8, err error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(s) != 6 {
return 0, 0, 0, fmt.Errorf("color must be #RRGGBB, got %q", s)
}
var v [3]uint8
for i := 0; i < 3; i++ {
var n int
if _, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &n); err != nil {
return 0, 0, 0, fmt.Errorf("bad hex color %q", s)
}
v[i] = uint8(n)
}
return v[0], v[1], v[2], nil
}
func cmdRender(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
out := fs.String("o", "text.png", "output PNG")
scale := fs.Int("scale", 1, "integer upscale")
col := fs.String("color", "#FFFFFF", "text color #RRGGBB")
parseInterspersed(fs, args)
if fs.NArg() != 2 {
die("render takes a .font file and a text string")
}
ft, err := font.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
r, g, b, err := parseHexColor(*col)
if err != nil {
die("%v", err)
}
img, warnings := ft.RenderText(fs.Arg(1), *scale, rgba(r, g, b))
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "fontc:", w)
}
f, err := os.Create(*out)
if err != nil {
die("%v", err)
}
defer f.Close()
if err := font.WritePNG(f, img); err != nil {
die("%v", err)
}
bd := img.Bounds()
fmt.Printf("%s (%dx%d px)\n", *out, bd.Dx(), bd.Dy())
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .font file")
}
ft, err := font.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("font: %s\n", ft.Name)
fmt.Printf("height: %d px\n", ft.Height)
fmt.Printf("line-height: %d px\n", ft.LineHeight)
fmt.Printf("baseline: %d\n", ft.Baseline)
fmt.Printf("spacing: %d px\n", ft.Spacing)
fmt.Printf("space-width: %d px\n", ft.SpaceWidth)
fmt.Printf("glyphs: %d\n", len(ft.Order))
var chars []string
for _, r := range ft.SortedChars() {
chars = append(chars, string(r))
}
fmt.Printf(" %s\n", strings.Join(chars, " "))
fmt.Println("valid: yes")
}
func cmdPreview(args []string) {
if len(args) != 2 {
die("preview takes a .font file and a text string")
}
ft, err := font.ParseFile(args[0])
if err != nil {
die("%v", err)
}
img, warnings := ft.RenderText(args[1], 1, rgba(255, 255, 255))
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "fontc:", w)
}
b := img.Bounds()
for y := 0; y < b.Dy(); y += 2 {
var sb strings.Builder
for x := 0; x < b.Dx(); x++ {
top := img.NRGBAAt(x, y).A >= 128
bot := y+1 < b.Dy() && img.NRGBAAt(x, y+1).A >= 128
switch {
case top && bot:
sb.WriteRune('█')
case top:
sb.WriteRune('▀')
case bot:
sb.WriteRune('▄')
default:
sb.WriteByte(' ')
}
}
fmt.Println(sb.String())
}
}
func rgba(r, g, b uint8) color.NRGBA {
return color.NRGBA{R: r, G: g, B: b, A: 255}
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "fontc: "+format+"\n", a...)
os.Exit(1)
}

54
cronr/README.md Normal file
View File

@@ -0,0 +1,54 @@
# cronr — agent scheduling via systemd user timers
Lets an agent create recurring or one-shot jobs that **survive session
exit and reboot** — what agy's in-memory `schedule` tool and Claude's
in-session wakeups cannot do. No daemon: systemd runs the jobs, cronr
just manages namespaced `cronr-<name>` units. Closes the
`CronCreate/List/Delete` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.1.
## Usage
```
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" # one-shot
cronr list # schedule, next run, last result per job
cronr run <name> # run the job right now (timer untouched)
cronr logs <name> [--lines N] # the job's journal
cronr rm <name> # disable and delete
```
Examples:
```bash
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"'
cronr add backup-ping --schedule "Mon *-*-* 09:00" --cmd 'notifyr send --msg "weekly backup check"'
cronr add once --at "2026-08-10 03:00" --cmd 'systemctl --user restart helmd'
```
## How it works
`add` writes three files and prints all of them, so the result is
fully verifiable:
- `~/.local/share/cronr/<name>.sh` — the command, verbatim (shell
quoting never meets systemd's ExecStart parsing)
- `~/.config/systemd/user/cronr-<name>.service` — oneshot, with
`~/.local/bin` on PATH so agent tools (notifyr, svgc, agy…) resolve
- `~/.config/systemd/user/cronr-<name>.timer``OnCalendar=…`,
`Persistent=true` for recurring jobs (missed runs fire on next boot)
Schedules are validated by `systemd-analyze calendar` before anything
is written — you get systemd's own error text plus the computed next
elapse. `list`/`rm` only ever see `cronr-*` units, so other services
are untouchable by construction.
`cronr run <name>` starts the service immediately — handy for testing
a job before trusting the schedule.
## Build & test
```bash
go test ./...
go build -o build/cronr .
```

3
cronr/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/cronr
go 1.24

300
cronr/main.go Normal file
View File

@@ -0,0 +1,300 @@
// cronr schedules recurring or one-shot jobs as systemd user timers,
// so an agent can set up work that survives session exit and reboot.
// No daemon of its own — systemd does the running. See
// doc/tool-parity.md §3.1.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit"
)
var version = "dev"
const usage = `cronr - agent-friendly scheduling via systemd user timers
Usage:
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot
cronr list all cronr jobs: schedule, next run, last result
cronr run <name> run the job now (does not touch the timer)
cronr logs <name> [--lines N] journal for the job
cronr rm <name> disable and delete the job
cronr version
Schedule examples (systemd OnCalendar; validated with systemd-analyze):
"*-*-* 07:00" every morning at 07:00
"Mon *-*-* 09:00" mondays 09:00
"*:0/15" every 15 minutes
The job's command is stored as a script in ~/.local/share/cronr/ and
runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy)
work as in a login shell. Everything cronr creates is namespaced
cronr-<name> — it never touches other units.
Example:
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"'
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "add":
cmdAdd(os.Args[2:])
case "list":
cmdList()
case "run":
requireName(os.Args[2:], "run")
sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service")
fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2])
case "logs":
cmdLogs(os.Args[2:])
case "rm":
cmdRm(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("cronr", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'cronr help'", os.Args[1])
}
}
func unitDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user")
}
func scriptDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "cronr")
}
func cmdAdd(args []string) {
fs := flag.NewFlagSet("add", flag.ExitOnError)
schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs")
at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`)
cmd := fs.String("cmd", "", "shell command the job runs (required)")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("add needs exactly one job name")
}
name := pos[0]
if !unit.ValidName(name) {
die("invalid job name %q (letters, digits, - and _)", name)
}
if *cmd == "" {
die("--cmd is required")
}
if (*schedule == "") == (*at == "") {
die("give exactly one of --schedule (recurring) or --at (one-shot)")
}
spec := *schedule
oneshot := false
if *at != "" {
var err error
spec, err = unit.AtToCalendar(*at)
if err != nil {
die("%v", err)
}
oneshot = true
}
// systemd itself is the authority on calendar specs
if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil {
die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out)))
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil {
die("job %s already exists (cronr rm %s first)", name, name)
}
scriptPath := filepath.Join(scriptDir(), name+".sh")
if err := os.MkdirAll(scriptDir(), 0o755); err != nil {
die("%v", err)
}
if err := os.MkdirAll(unitDir(), 0o755); err != nil {
die("%v", err)
}
writes := []struct{ path, content string }{
{scriptPath, unit.Script(*cmd)},
{filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)},
{filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)},
}
for _, w := range writes {
mode := os.FileMode(0o644)
if strings.HasSuffix(w.path, ".sh") {
mode = 0o755
}
if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil {
die("%v", err)
}
}
sh("systemctl", "--user", "daemon-reload")
sh("systemctl", "--user", "enable", "--now", tmrName)
fmt.Printf("job %s created and enabled\n\n", name)
for _, w := range writes {
fmt.Printf("--- %s ---\n%s\n", w.path, w.content)
}
fmt.Print(nextRun(spec))
}
func cmdList() {
matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer"))
if len(matches) == 0 {
fmt.Println("no cronr jobs")
return
}
sort.Strings(matches)
fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT")
for _, m := range matches {
name := unit.JobName(m)
if name == "" {
continue
}
spec := ""
if data, err := os.ReadFile(m); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "OnCalendar=") {
spec = strings.TrimPrefix(line, "OnCalendar=")
}
}
}
next := strings.TrimPrefix(nextRun(spec), "next run: ")
svcName, _ := unit.UnitNames(name)
last := lastResult(svcName)
fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last)
}
}
// nextRun asks systemd-analyze when the spec fires next.
func nextRun(spec string) string {
out, err := exec.Command("systemd-analyze", "calendar", spec).Output()
if err != nil {
return "next run: ?\n"
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Next elapse:") {
return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n"
}
}
return "next run: never (already elapsed?)\n"
}
func lastResult(svcName string) string {
out, err := exec.Command("systemctl", "--user", "show", svcName,
"-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output()
if err != nil {
return "?"
}
status, when := "?", ""
for _, line := range strings.Split(string(out), "\n") {
if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok {
status = v
}
if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok {
when = v
}
}
if when == "" {
return "never ran"
}
if status == "0" {
return "ok (" + when + ")"
}
return "exit " + status + " (" + when + ")"
}
func cmdLogs(args []string) {
fs := flag.NewFlagSet("logs", flag.ExitOnError)
lines := fs.Int("lines", 50, "number of journal lines")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("logs needs exactly one job name")
}
svcName, _ := unit.UnitNames(pos[0])
c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager")
c.Stdout, c.Stderr = os.Stdout, os.Stderr
c.Run()
}
func cmdRm(args []string) {
requireName(args, "rm")
name := args[0]
if !unit.ValidName(name) {
die("invalid job name %q", name)
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil {
die("no such job %s", name)
}
sh("systemctl", "--user", "disable", "--now", tmrName)
for _, p := range []string{
filepath.Join(unitDir(), tmrName),
filepath.Join(unitDir(), svcName),
filepath.Join(scriptDir(), name+".sh"),
} {
os.Remove(p)
}
sh("systemctl", "--user", "daemon-reload")
fmt.Printf("job %s removed\n", name)
}
func requireName(args []string, cmd string) {
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
die("%s needs a job name", cmd)
}
}
// sh runs a command, dying with its output on failure — every call
// here is a systemctl whose failure should stop the operation.
func sh(name string, args ...string) {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...)
os.Exit(1)
}

96
cronr/unit/unit.go Normal file
View File

@@ -0,0 +1,96 @@
// Package unit generates the systemd user units cronr manages. Pure
// text generation — systemd does the scheduling, cronr owns no daemon.
package unit
import (
"fmt"
"regexp"
"strings"
)
// Prefix namespaces everything cronr creates so list/rm can never
// touch units it does not own.
const Prefix = "cronr-"
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// ValidName reports whether a job name is safe for unit/file names.
func ValidName(name string) bool {
return len(name) <= 64 && nameRe.MatchString(name)
}
// Script wraps the job command in an executable shell script — the
// unit ExecStart points here, so arbitrary quoting in the command
// never meets systemd's ExecStart parsing.
func Script(cmd string) string {
return "#!/bin/sh\n# generated by cronr - the job's command lives here so systemd\n# unit quoting never mangles it\n" + cmd + "\n"
}
// Service renders the .service unit. PATH gets ~/.local/bin first so
// jobs can call agent tools (notifyr, svgc, agy, ...) like a login
// shell would.
func Service(name, scriptPath string) string {
return fmt.Sprintf(`[Unit]
Description=cronr job %s
[Service]
Type=oneshot
Environment=PATH=%%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/sh %s
`, name, scriptPath)
}
// Timer renders the .timer unit. Recurring jobs get Persistent=true
// (a missed run fires at next boot/login); one-shots do not.
func Timer(name, calendarSpec string, oneshot bool) string {
persistent := "true"
if oneshot {
persistent = "false"
}
return fmt.Sprintf(`[Unit]
Description=cronr timer for %s
[Timer]
OnCalendar=%s
Persistent=%s
[Install]
WantedBy=timers.target
`, name, calendarSpec, persistent)
}
// UnitNames returns the service and timer unit names for a job.
func UnitNames(name string) (service, timer string) {
return Prefix + name + ".service", Prefix + name + ".timer"
}
// JobName extracts the job name from a cronr unit filename, or ""
// if the filename is not cronr's.
func JobName(unitFile string) string {
base := unitFile
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if !strings.HasPrefix(base, Prefix) {
return ""
}
base = strings.TrimPrefix(base, Prefix)
for _, suffix := range []string{".timer", ".service"} {
if strings.HasSuffix(base, suffix) {
return strings.TrimSuffix(base, suffix)
}
}
return ""
}
// AtToCalendar converts "YYYY-MM-DD HH:MM" (or with seconds) to a
// systemd calendar spec for one-shot jobs. systemd accepts the format
// as-is; this just validates the shape early with a helpful error.
var atRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$`)
func AtToCalendar(at string) (string, error) {
if !atRe.MatchString(at) {
return "", fmt.Errorf("--at must be \"YYYY-MM-DD HH:MM[:SS]\", got %q", at)
}
return at, nil
}

84
cronr/unit/unit_test.go Normal file
View File

@@ -0,0 +1,84 @@
package unit
import (
"strings"
"testing"
)
func TestValidName(t *testing.T) {
for _, ok := range []string{"nightly-ci-check", "a", "Job_2", "x1-y2"} {
if !ValidName(ok) {
t.Errorf("%q should be valid", ok)
}
}
for _, bad := range []string{"", "-leading", "has space", "slash/y", "ä", strings.Repeat("x", 65), "dot.name"} {
if ValidName(bad) {
t.Errorf("%q should be invalid", bad)
}
}
}
func TestScriptKeepsCommandVerbatim(t *testing.T) {
cmd := `agy -p "check CI, say 'hi' & notify" | tee /tmp/x`
s := Script(cmd)
if !strings.HasPrefix(s, "#!/bin/sh\n") {
t.Error("missing shebang")
}
if !strings.Contains(s, cmd) {
t.Error("command was mangled")
}
}
func TestServiceUnit(t *testing.T) {
s := Service("nightly", "/home/x/.local/share/cronr/nightly.sh")
for _, want := range []string{
"Type=oneshot",
"ExecStart=/bin/sh /home/x/.local/share/cronr/nightly.sh",
"Environment=PATH=%h/.local/bin",
} {
if !strings.Contains(s, want) {
t.Errorf("service missing %q:\n%s", want, s)
}
}
}
func TestTimerUnit(t *testing.T) {
rec := Timer("nightly", "*-*-* 07:00", false)
if !strings.Contains(rec, "OnCalendar=*-*-* 07:00") || !strings.Contains(rec, "Persistent=true") {
t.Errorf("recurring timer wrong:\n%s", rec)
}
once := Timer("boot", "2026-08-06 03:00", true)
if !strings.Contains(once, "Persistent=false") {
t.Errorf("one-shot timer should not be persistent:\n%s", once)
}
if !strings.Contains(once, "WantedBy=timers.target") {
t.Error("timer missing install section")
}
}
func TestUnitAndJobNames(t *testing.T) {
svc, tmr := UnitNames("nightly")
if svc != "cronr-nightly.service" || tmr != "cronr-nightly.timer" {
t.Errorf("unit names: %s %s", svc, tmr)
}
if JobName("/home/x/.config/systemd/user/cronr-nightly.timer") != "nightly" {
t.Error("JobName failed on full path")
}
if JobName("helmd.service") != "" {
t.Error("foreign unit must not map to a job")
}
}
func TestAtToCalendar(t *testing.T) {
if _, err := AtToCalendar("2026-08-06 03:00"); err != nil {
t.Errorf("valid --at rejected: %v", err)
}
if _, err := AtToCalendar("2026-08-06 03:00:30"); err != nil {
t.Errorf("valid --at with seconds rejected: %v", err)
}
for _, bad := range []string{"imorgon", "03:00", "2026-8-6 03:00", "2026-08-06T03:00"} {
if _, err := AtToCalendar(bad); err == nil {
t.Errorf("bad --at %q accepted", bad)
}
}
}

63
doc/plan.md Normal file
View File

@@ -0,0 +1,63 @@
# agent-tools — plan
## Goal
A collection of small, agent-friendly CLI tools. The common thread:
**text in, verifiable artifacts out**. An agent should be able to author
the input format directly, predict what the output will look like, and
verify results without a GUI.
## Stack
- Go (single static binaries, zero deps, trivial cross-compile to the
Pi5's arm64 runner), one Go module per tool.
- Targets: linux x64 + linux arm64.
## Deploy target
Pattern A (distributable binaries): rolling `<tool>-latest` release per
tool on Gitea, built by `.gitea/workflows/release.yml` on push to
master/main. Only tools whose folders changed get rebuilt.
## Milestones
1.`pixel-sprite-maker` (`spritec`): .sprite text format → PNG/JPG/SVG,
sprite sheets with self-documenting file names, terminal preview.
2.`mesh-tool` (`mesht`): OBJ/STL create/inspect/edit with ASCII
multi-view rendering, measurements and watertightness checks.
3. ✅ Per-tool VS Code build tasks → `<tool>/build/`.
4. ✅ CI: changed-tool detection + per-tool rolling releases.
## Roadmap / expansion ideas (not agreed yet)
- spritec: palette import from image files, GIF export for animations,
tile-map composer (map file referencing sprite tiles).
- mesht: OBJ vertex-color support, simple boolean ops (union via
voxelization), PNG snapshot rendering, glTF export.
- New tools: bitmap-font maker, sound-effect generator (sfxr-style text
presets), tiled-map (.tmx) writer.
### Agent-capability tools (see [tool-parity.md](tool-parity.md))
Close the gap between Gemini CLI and Claude Code, plus homelab admin
tools grounded in infra-Doc history. Suggested order:
1. `notifyr` — send/read on the existing Pi5 ntfy bus (≈ PushNotification).
2. `giteactl` — Gitea Actions runs, zst CI logs, `wait`/`wait-quiet`
build serialization, releases.
3. `waitfor` — block until a condition holds (≈ Monitor);
`cronr` — systemd-user-timer scheduling (≈ CronCreate/List/Delete).
4. `fleet` — one-shot homelab health snapshot via the read-only
claude-docker wrapper.
5. `envaudit` — compose ↔ `.env` key/inline-secret audit;
`reghelper` — registry catalog + prune *plans*;
`pagepub` — publish HTML/MD report to a URL (≈ Artifact);
`nbcell` — Jupyter cell editing (≈ NotebookEdit);
`wtreectl` — disposable git worktrees.
6. `fanout` — parallel headless-agent orchestration (≈ Workflow); only
on concrete need.
## Open questions
- Versioned releases (`vX.Y.Z` tags per tool) on top of the rolling
`latest` — add when something depends on a pinned version.

297
doc/tool-parity.md Normal file
View File

@@ -0,0 +1,297 @@
# Tool parity: Google's agent vs Claude Code
Goal: let Google's agent CLI do everything Claude Code can, by building
the missing capabilities as small CLI tools in this repo. The agent
calls them through its shell tool, so every tool follows the house
rules: **text in, verifiable artifacts out**, single static Go binary,
self-explanatory output.
**Which Google agent?** Legacy `gemini-cli` is auth-dead for personal
accounts (verified again 2026-08-05, see infra-Doc
`hosts/brasse-linux01.md`). The real target is **`agy` (Antigravity
CLI)** — live-tested in section 2, and its toolset differs from the
old Gemini CLI docs. Section 1's table is kept for reference since
Gemini CLI still exists in API-key mode.
A design rule that fell out of the live test: **a dedicated binary
beats ad-hoc shell because of approval prefixes.** agy (like Claude
Code) allowlists commands by prefix — `notifyr …` can be approved once
and forever, while every hand-rolled `for i in $(seq …); do curl …`
loop is a unique string that needs fresh human approval. Small stable
CLIs are therefore not just convenience: they are what makes
unattended agent operation possible at all.
Sources: Gemini CLI tools reference (<https://geminicli.com/docs/reference/tools/>),
Claude Code's toolset as of 2026-08, live probing of `agy` 1.1.9.
## 1. Already at parity — nothing to build
| Capability | Claude Code | Gemini CLI |
|---|---|---|
| Read/write/edit files | `Read` / `Write` / `Edit` | `read_file` / `write_file` / `replace` |
| Find files / search text / list dirs | `Glob` / `Grep` | `glob` / `grep_search` / `list_directory`, plus `read_many_files` |
| Shell, incl. background processes | `Bash` (+ background tasks) | `run_shell_command` (+ background processes) |
| Web | `WebFetch` / `WebSearch` | `web_fetch` / `google_web_search` |
| Ask the user a structured question | `AskUserQuestion` | `ask_user` |
| Plan mode | `EnterPlanMode` / `ExitPlanMode` | `enter_plan_mode` / `exit_plan_mode` |
| Skills / slash commands | `Skill` (`.claude/skills`) | `activate_skill` (`.gemini/skills`) |
| Persistent memory | file-based memory dir | `save_memory` (simpler, but exists) |
| Todo/task tracking | `TaskCreate`/`TaskUpdate`/… | `write_todos`, `tracker_*` (experimental) |
| MCP servers + resources | MCP tools, `ListMcpResources`/`ReadMcpResource` | MCP tools, `list_mcp_resources`/`read_mcp_resource` |
| Subagents | `Agent` (background, custom types) | subagents (experimental) — weaker, see `fanout` below |
Not worth replicating (harness-internal to Claude Code, no value as a
CLI): `ToolSearch`, `EndConversation`, `ReportFindings`,
`ShareOnboardingGuide`, `DesignSync`, remote cloud execution.
## 2. Live test 2026-08-05: `agy` (Antigravity CLI 1.1.9)
Tested interactively in a tmux session (Google AI Pro account, model
Gemini 3.6 Flash). Its 19 built-in tools, self-enumerated:
`ask_permission`, `ask_question`, `define_subagent`, `generate_image`,
`grep_search`, `invoke_subagent`, `list_dir`, `list_permissions`,
`manage_subagents`, `manage_task`, `multi_replace_file_content`,
`read_url_content`, `replace_file_content`, `run_command`, `schedule`,
`search_web`, `send_message`, `view_file`, `write_to_file`.
What this changes vs the old Gemini CLI picture:
- **agy has real subagents** (`invoke_subagent`/`define_subagent`/
`manage_subagents` + `send_message`) and background-task management
(`manage_task`). → `fanout` demoted further; probably never needed.
- **agy has `schedule`** — one-shot timer or cron expression that wakes
the agent with a prompt (same idea as Claude's `ScheduleWakeup`).
Confirmed limits, from its schema: it cannot run commands itself,
it is **in-memory and dies with the session**, and it cannot reach
the phone. → `cronr` (persistent systemd timers) and `notifyr` are
still needed; `schedule` complements them within a session.
- **agy has `generate_image`** — a *reverse* gap: Claude Code has no
native image generation. Nothing to build; just worth knowing.
- No MCP-resource tools, no memory tool and no glob in its toolset
(grep/list_dir cover finding files).
Behavior tests run in a scratch arena:
| Test | Result |
|---|---|
| Enumerate tools | Clean list of 19 (above) |
| Edit a text file | Worked, auto-approved in trusted folder |
| Edit a Jupyter cell, keep `.ipynb` valid | **Passed** — it wrote a `python3 -c` json script rather than text-replacing. Notebook stayed valid. Cost: a per-command approval each time → `nbcell` demoted to nice-to-have (stable prefix + no ad-hoc python). |
| Wait for a file to appear | Worked via a hand-rolled `for … sleep 1` shell loop — a unique command string needing fresh approval. → exactly the `waitfor` case. |
| Asked agy which CLI tools *it* wants for the homelab | Its list: ntfy client, `tea` (Gitea CLI), `skopeo`/`crane` (registry), `ofelia`/cron daemon, `ctop`-style fleet status — near-1:1 with section 4, and it independently made the approval-prefix argument. |
**Buy before build:** agy's suggestions overlap with off-the-shelf
tools. Evaluate first: `tea` (official Gitea CLI — but it does not read
the Pi5's zst action logs and has no `wait-quiet`, which stay
`giteactl`'s reason to exist, possibly as a thin layer *on top of*
`tea`), `skopeo`/`crane` (cover most of `reghelper` — remaining value
is prune *plans* and size summaries), `ctop` (interactive TUI, not
agent-friendly output — `fleet` still wins for agents).
## 3. Gaps → tools to build
Ordered by expected value. Each becomes its own folder + binary +
README, per repo convention.
### 3.1 `cronr` — scheduled/recurring runs *(Claude: `CronCreate`/`CronList`/`CronDelete`, `/loop`)*
agy's built-in `schedule` dies with the session (see section 2).
`cronr` manages **systemd user timers** so an agent can create
recurring or one-shot jobs that survive session exit and reboot
(including "run this prompt every morning" via `agy -p …`).
```
cronr add nightly-ci-check --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"'
cronr add once-reboot-check --at "2026-08-06 03:00" --cmd '…' # one-shot
cronr list # name, schedule, next run, last result
cronr logs nightly-ci-check # journalctl for the unit
cronr rm nightly-ci-check
```
Output prints the generated unit files so the result is verifiable.
No daemon of its own — systemd does the running.
### 3.2 `waitfor` — block until a condition holds *(Claude: `Monitor`)*
Turns "poll every N seconds" into a single blocking tool call, so the
agent doesn't burn turns polling.
```
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
waitfor --cmd "ssh pi5 docker ps --format '{{.Names}}'" --matches 'gitea' --timeout 10m
waitfor … --then 'notifyr send --msg "gitea is back up"'
```
Exit 0 = condition met, exit 3 = timeout; last output is printed either
way. `--then` runs a command on success (composes with `notifyr`).
### 3.3 `notifyr` — push notifications, send **and read** *(Claude: `PushNotification`)*
The ntfy server **already runs on the Pi5** and is the house-wide
notification bus (topics like `Info`, `pi5-server-fel`, `ci-fel` — see
infra-Doc `services/observability.md`). `notifyr` is a thin client so
every agent uses it the same way:
```
notifyr send --topic ci-fel --title "Build failed" --msg "agent-tools arm64 test: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # poll mode: what has alerted lately?
```
`read` (ntfy's `?poll=1&since=…`) is the underrated half: it lets an
agent *check what the infra has been complaining about* before/after a
change. Config (`~/.config/notifyr/config.json`): server URL + token.
### 3.4 `pagepub` — publish an HTML/Markdown report to a URL *(Claude: `Artifact`)*
Claude Code can publish reports as web pages; Gemini cannot. `pagepub`
rsyncs a file to a static-file host on the Pi5 (nginx container behind
NPM, e.g. `pages.brasse-pc.eu`) and prints the stable URL.
```
pagepub publish report.html --slug ci-report → https://pages.brasse-pc.eu/ci-report/
pagepub publish notes.md --slug pi5-audit # .md rendered to HTML with built-in template
pagepub list | rm <slug>
```
Requires the static host to exist first (small infra task; goes in
infra-Doc + NPM proxy host via `npmctl`).
### 3.5 `nbcell` — Jupyter notebook editing *(Claude: `NotebookEdit`)*
`.ipynb` is JSON that's miserable to edit via `replace`. `nbcell`
exposes cells as text:
```
nbcell list nb.ipynb # index, type, first line, exec count
nbcell show nb.ipynb 3 # cell source (and outputs with --outputs)
nbcell edit nb.ipynb 3 --from-file cell.py
nbcell add nb.ipynb --at 4 --type code --from-file new.py
nbcell rm nb.ipynb 7
```
### 3.6 `wtreectl` — disposable git worktrees *(Claude: worktree isolation for agents)*
Claude Code can give each subagent an isolated git worktree. `wtreectl`
does the same for any agent:
```
wtreectl new [--branch dev/foo] # prints the new worktree path
wtreectl list
wtreectl clean # removes worktrees with no changes
```
Lets two agent sessions work in the same repo without trampling each
other.
### 3.7 `fanout` — parallel subagent orchestration *(Claude: `Workflow`, `Agent`)* — roadmap, not agreed
Runs N prompts as parallel headless agent processes (`gemini -p` /
`claude -p`) with a concurrency cap, collecting each result as JSON in
an output dir. A poor man's `Workflow`:
```
fanout run jobs.json --max 3 --out results/
```
Heavier than the other tools and overlaps with agent-helm's territory —
park until there's a concrete need.
## 4. Suggested tools from infra history
Grounded in what the agent has already been doing per infra-Doc
(`maintenance-and-gaps.md`, `services/source-control-and-deploy.md`,
`services/observability.md`, the per-service "operational quick-ref"
blocks). These help **any** agent (Claude or Gemini) administer the
fleet, and they respect the read-only sudo policy
(`ssh/claude-sudo-policy.md`): everything below is read-or-notify;
mutations still go through Björn's supervised tmux flow.
### 4.1 `giteactl` — Gitea repos, Actions runs and CI logs
The biggest recurring friction. Today: CI status is polled ad hoc, and
logs for private repos are only readable as zst files under
`/srv/storage1/gitea/actions_log/…` on the Pi5. Wraps the Gitea REST +
Actions API:
```
giteactl runs <repo> [--limit 5] # status, branch, duration
giteactl log <repo> <run> [--job N] # fetches + decompresses the zst log
giteactl wait <repo> [--timeout 30m] # block until latest run finishes; exit 0 = green
giteactl wait-quiet [--max-active 1] # block until ≤N heavy builds are running
giteactl release <repo> [<tag>] # rolling-release assets + checksums
```
`wait-quiet` encodes the hard-learned rule "serialize pushes — >2 heavy
builds take the Pi5 down": `giteactl wait-quiet && git push`.
Composes with `waitfor`/`notifyr`.
### 4.2 `fleet` — one-shot health snapshot of the whole homelab
Every service doc ends with the same hand-rolled loop over
`ssh pi5-claude sudo claude-docker ps/inspect …`. `fleet` does that
loop once, properly:
```
fleet status # containers (state, image, restarts), disk/mergerfs fill %, failed systemd units
fleet status --host brasse-linux01
fleet checks # Uptime Kuma monitor states + last ntfy alerts (via notifyr read)
```
Read-only by construction (claude-docker wrapper + sudo allowlist), so
it needs no new permissions. Output is a stable text table an agent can
diff between runs.
### 4.3 `envaudit` — compose ↔ `.env` key auditor
Grounded in a real incident (`${STORAGE1}` undefined in `/srv/.env`
bad mount, rollback) and in maintenance-and-gaps' inline-secret
findings:
```
envaudit check /srv/dockge-staks --env /srv/.env
→ UNDEFINED ${STORAGE1} used by media-stack/compose.yaml
→ INLINE LDAP_ADMIN_PASSWORD hardcoded in openldap/compose.yaml (should live in .env)
→ UNUSED OLD_API_KEY defined but referenced nowhere
```
Pure text analysis of compose files — safe to run anywhere, catches the
two failure classes that have actually happened.
### 4.4 `reghelper` — docker-registry catalog & hygiene
The LAN registry (`192.168.0.19:5000`) has no UI, no auth and no
cleanup story, and the backup plan explicitly wants it slimmed:
```
reghelper ls # catalog + tags + image sizes
reghelper tags <image>
reghelper prune-plan --keep 2 # prints the delete+GC commands (does NOT run them)
```
`prune-plan` deliberately only *prints* the mutation commands for the
supervised tmux flow — same pattern as the sudo policy.
### 4.5 Backup status reader — once Backrest/restic exists
future-plans.md has the whole Backrest+restic design chosen but
unbuilt. When it lands, a `fleet backups` subcommand (last snapshot age
per source, repo size, last check result) closes the loop — an agent
can then *verify* backups instead of trusting them. Not a separate
tool; park under `fleet`.
### Cross-reference
`/home/brasse/repos/dify-agent-tools/` already has a specced-but-unbuilt
set of FastAPI tools (file/image sorting, face recognition, ST-card
export…) for the Dify platform. Different runtime (HTTP tools vs CLI
binaries), same philosophy — don't duplicate those here.
## 5. Suggested build order
1. `notifyr` — smallest, everything else composes with it, ntfy already runs.
2. `giteactl` — removes the biggest daily friction (CI logs + build serialization).
3. `waitfor` + `cronr` — turns both agents into unattended operators.
4. `fleet` — replaces the hand-rolled health loops in every runbook.
5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed.
6. `fanout` — only if a concrete multi-agent need shows up.

66
hitbox-tool/README.md Normal file
View File

@@ -0,0 +1,66 @@
# hitbox-tool (`hitbox`)
Scans **sprite sheet PNGs** and writes per-frame **collision boxes as
JSON**, computed from the alpha channel. Closes the loop with
[`pixel-sprite-maker`](../pixel-sprite-maker/): render a sheet with
`spritec`, scan it with `hitbox`, and your game gets both graphics and
hitboxes without a human drawing rectangles. Go, zero dependencies,
single static binary.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd hitbox-tool
go build -o build/hitbox . # or the VS Code task "build hitbox-tool"
go test ./...
```
## Usage
```bash
# spritec-named sheets need no flags — layout is parsed from the name,
# and integer upscales are auto-detected (a _8x8_4x1 sheet that is
# 256x64 px was rendered at --scale 8, so cells are 64x64):
hitbox scan walk_8x8_4x1.png -o walk.hitbox.json
hitbox scan boss.png --cell 32x32 -o boss.json # explicit frame size
hitbox scan portrait.png # whole image = one frame, JSON to stdout
hitbox show walk_8x8_4x1.png --frame 2 # verify visually in the terminal
# tuning:
--threshold 128 # ignore faint pixels (alpha < 128)
--shrink 1 # tighter, more forgiving hitboxes (n px per side)
--pad 2 # generous hitboxes (e.g. pickups)
```
## Output
```json
{
"image": "walk_8x8_4x1.png",
"cellW": 8, "cellH": 8, "cols": 4, "rows": 1,
"alphaThreshold": 1,
"frames": [
{ "index": 0, "col": 0, "row": 0, "empty": false,
"box": { "x": 1, "y": 0, "w": 5, "h": 8 } },
{ "index": 1, "col": 1, "row": 0, "empty": true, "box": null }
]
}
```
- Boxes are **relative to each frame's top-left corner**.
- Frame order is row-major (`index = row * cols + col`), matching
spritec sheets.
- Cells with no solid pixels get `"empty": true`.
In game code:
```
hit = px >= frameX + box.x && px < frameX + box.x + box.w
&& py >= frameY + box.y && py < frameY + box.y + box.h
```
`hitbox show` draws each frame with `#` for solid pixels and `+` for the
box outline, so an agent can verify the result without an image viewer.

View File

@@ -0,0 +1,58 @@
{
"image": "examples/walk_8x8_4x1.png",
"cellW": 64,
"cellH": 64,
"cols": 4,
"rows": 1,
"alphaThreshold": 1,
"frames": [
{
"index": 0,
"col": 0,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 1,
"col": 1,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 2,
"col": 2,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 3,
"col": 3,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

3
hitbox-tool/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool
go 1.24

View File

@@ -0,0 +1,230 @@
// Package hitbox computes per-frame collision boxes from sprite sheet
// images by scanning the alpha channel.
package hitbox
import (
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
)
// Box is a rectangle relative to its frame's top-left corner.
type Box struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// Frame is the scan result for one sheet cell.
type Frame struct {
Index int `json:"index"`
Col int `json:"col"`
Row int `json:"row"`
Empty bool `json:"empty"`
Box *Box `json:"box"` // nil when Empty
}
// Sheet is the full scan result; the JSON deliverable.
type Sheet struct {
Image string `json:"image"`
CellW int `json:"cellW"`
CellH int `json:"cellH"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Threshold int `json:"alphaThreshold"`
Frames []Frame `json:"frames"`
}
// layoutRe matches the spritec sheet naming convention:
// <base>_<cellW>x<cellH>_<cols>x<rows>.<ext>
var layoutRe = regexp.MustCompile(`_(\d+)x(\d+)_(\d+)x(\d+)\.[A-Za-z]+$`)
// LayoutFromName extracts cell size and grid from a spritec-style file
// name. ok is false when the name doesn't follow the convention.
func LayoutFromName(path string) (cellW, cellH, cols, rows int, ok bool) {
m := layoutRe.FindStringSubmatch(filepath.Base(path))
if m == nil {
return 0, 0, 0, 0, false
}
cellW, _ = strconv.Atoi(m[1])
cellH, _ = strconv.Atoi(m[2])
cols, _ = strconv.Atoi(m[3])
rows, _ = strconv.Atoi(m[4])
return cellW, cellH, cols, rows, true
}
// InferScale detects integer-upscaled sheets: when the image is exactly
// s times bigger than the name-declared layout (both axes, s >= 1), the
// real cell size is cell*s. Returns 0 when the layout doesn't fit.
func InferScale(imgW, imgH, cellW, cellH, cols, rows int) int {
baseW, baseH := cellW*cols, cellH*rows
if baseW <= 0 || baseH <= 0 || imgW%baseW != 0 || imgH%baseH != 0 {
return 0
}
s := imgW / baseW
if s < 1 || imgH/baseH != s {
return 0
}
return s
}
// LoadPNG reads a PNG image from disk.
func LoadPNG(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("%s: %w (only PNG is supported — sheets need an alpha channel)", path, err)
}
return img, nil
}
// Scan computes a tight box per frame: the smallest rectangle covering
// every pixel with alpha >= threshold. shrink/pad (in pixels) contract or
// expand each box afterwards, clamped to the cell.
func Scan(img image.Image, name string, cellW, cellH, threshold, shrink, pad int) (*Sheet, error) {
b := img.Bounds()
if cellW <= 0 || cellH <= 0 {
cellW, cellH = b.Dx(), b.Dy() // whole image = one frame
}
if b.Dx()%cellW != 0 || b.Dy()%cellH != 0 {
return nil, fmt.Errorf("image is %dx%d which is not divisible by cell %dx%d",
b.Dx(), b.Dy(), cellW, cellH)
}
if threshold < 1 || threshold > 255 {
return nil, fmt.Errorf("alpha threshold %d out of range 1-255", threshold)
}
cols, rows := b.Dx()/cellW, b.Dy()/cellH
sheet := &Sheet{
Image: name, CellW: cellW, CellH: cellH,
Cols: cols, Rows: rows, Threshold: threshold,
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
fr := Frame{Index: row*cols + col, Col: col, Row: row}
minX, minY := cellW, cellH
maxX, maxY := -1, -1
for y := 0; y < cellH; y++ {
for x := 0; x < cellW; x++ {
_, _, _, a := img.At(b.Min.X+col*cellW+x, b.Min.Y+row*cellH+y).RGBA()
if int(a>>8) >= threshold {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
}
if maxX < 0 {
fr.Empty = true
} else {
box := Box{X: minX, Y: minY, W: maxX - minX + 1, H: maxY - minY + 1}
box = adjust(box, shrink-pad, cellW, cellH)
fr.Box = &box
}
sheet.Frames = append(sheet.Frames, fr)
}
}
return sheet, nil
}
// adjust contracts the box by delta px on every side (negative delta
// expands), clamped to the cell and to a minimum size of 1x1.
func adjust(b Box, delta, cellW, cellH int) Box {
b.X += delta
b.Y += delta
b.W -= 2 * delta
b.H -= 2 * delta
if b.X < 0 {
b.W += b.X
b.X = 0
}
if b.Y < 0 {
b.H += b.Y
b.Y = 0
}
if b.X+b.W > cellW {
b.W = cellW - b.X
}
if b.Y+b.H > cellH {
b.H = cellH - b.Y
}
if b.W < 1 {
b.W = 1
if b.X > cellW-1 {
b.X = cellW - 1
}
}
if b.H < 1 {
b.H = 1
if b.Y > cellH-1 {
b.Y = cellH - 1
}
}
return b
}
// WriteJSON encodes the sheet as indented JSON.
func (s *Sheet) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(s)
}
// Ascii draws one frame with '#' for solid pixels and '+' for the box
// outline, so results can be verified in a terminal.
func Ascii(img image.Image, s *Sheet, index, threshold int) string {
if index < 0 || index >= len(s.Frames) {
return "(no such frame)\n"
}
fr := s.Frames[index]
b := img.Bounds()
out := make([]rune, 0, (s.CellW+1)*s.CellH)
onBoxEdge := func(x, y int) bool {
if fr.Box == nil {
return false
}
bx := fr.Box
inX := x >= bx.X && x < bx.X+bx.W
inY := y >= bx.Y && y < bx.Y+bx.H
edgeX := x == bx.X || x == bx.X+bx.W-1
edgeY := y == bx.Y || y == bx.Y+bx.H-1
return (inX && inY) && (edgeX || edgeY)
}
for y := 0; y < s.CellH; y++ {
for x := 0; x < s.CellW; x++ {
_, _, _, a := img.At(b.Min.X+fr.Col*s.CellW+x, b.Min.Y+fr.Row*s.CellH+y).RGBA()
solid := int(a>>8) >= threshold
switch {
case solid && onBoxEdge(x, y):
out = append(out, '#')
case solid:
out = append(out, '#')
case onBoxEdge(x, y):
out = append(out, '+')
default:
out = append(out, '.')
}
}
out = append(out, '\n')
}
return string(out)
}

View File

@@ -0,0 +1,137 @@
package hitbox
import (
"bytes"
"encoding/json"
"image"
"image/color"
"testing"
)
// sheet4 builds a 2x1 sheet of 4x4 cells: frame 0 has a 2x2 blob at
// (1,1); frame 1 is empty.
func sheet4() image.Image {
img := image.NewNRGBA(image.Rect(0, 0, 8, 4))
for y := 1; y <= 2; y++ {
for x := 1; x <= 2; x++ {
img.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
}
}
return img
}
func TestScanTightBox(t *testing.T) {
s, err := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
if err != nil {
t.Fatal(err)
}
if s.Cols != 2 || s.Rows != 1 || len(s.Frames) != 2 {
t.Fatalf("layout %dx%d frames=%d", s.Cols, s.Rows, len(s.Frames))
}
f0 := s.Frames[0]
if f0.Empty || f0.Box == nil {
t.Fatal("frame 0 should have a box")
}
if *f0.Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
t.Errorf("frame 0 box = %+v", *f0.Box)
}
f1 := s.Frames[1]
if !f1.Empty || f1.Box != nil {
t.Errorf("frame 1 should be empty, got %+v", f1)
}
}
func TestScanWholeImageDefault(t *testing.T) {
s, err := Scan(sheet4(), "x.png", 0, 0, 1, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(s.Frames) != 1 || s.CellW != 8 || s.CellH != 4 {
t.Errorf("whole-image scan: %+v", s)
}
}
func TestScanErrors(t *testing.T) {
if _, err := Scan(sheet4(), "x.png", 3, 4, 1, 0, 0); err == nil {
t.Error("non-divisible cell size should error")
}
if _, err := Scan(sheet4(), "x.png", 4, 4, 0, 0, 0); err == nil {
t.Error("threshold 0 should error")
}
}
func TestShrinkAndPad(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 255})
}
}
s, _ := Scan(img, "x.png", 4, 4, 1, 1, 0) // shrink 1
if *s.Frames[0].Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
t.Errorf("shrunk box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 4, 4, 1, 0, 3) // pad clamps to cell
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 4, H: 4}) {
t.Errorf("padded box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 4, 4, 1, 10, 0) // over-shrink -> min 1x1
if s.Frames[0].Box.W < 1 || s.Frames[0].Box.H < 1 {
t.Errorf("over-shrunk box = %+v", *s.Frames[0].Box)
}
}
func TestThreshold(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 2, 1))
img.SetNRGBA(0, 0, color.NRGBA{0, 0, 0, 100})
img.SetNRGBA(1, 0, color.NRGBA{0, 0, 0, 200})
s, _ := Scan(img, "x.png", 2, 1, 150, 0, 0)
if *s.Frames[0].Box != (Box{X: 1, Y: 0, W: 1, H: 1}) {
t.Errorf("threshold box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 2, 1, 50, 0, 0)
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 2, H: 1}) {
t.Errorf("low-threshold box = %+v", *s.Frames[0].Box)
}
}
func TestLayoutFromName(t *testing.T) {
w, h, c, r, ok := LayoutFromName("/tmp/walk_8x8_4x1.png")
if !ok || w != 8 || h != 8 || c != 4 || r != 1 {
t.Errorf("parsed %d %d %d %d ok=%v", w, h, c, r, ok)
}
if _, _, _, _, ok := LayoutFromName("plain.png"); ok {
t.Error("plain.png should not match")
}
}
func TestInferScale(t *testing.T) {
// walk_8x8_4x1.png rendered at scale 8 -> 256x64
if s := InferScale(256, 64, 8, 8, 4, 1); s != 8 {
t.Errorf("scale = %d, want 8", s)
}
if s := InferScale(32, 8, 8, 8, 4, 1); s != 1 {
t.Errorf("unscaled = %d, want 1", s)
}
if s := InferScale(250, 64, 8, 8, 4, 1); s != 0 {
t.Errorf("non-divisible = %d, want 0", s)
}
if s := InferScale(256, 32, 8, 8, 4, 1); s != 0 {
t.Errorf("axis mismatch = %d, want 0", s)
}
}
func TestJSONShape(t *testing.T) {
s, _ := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
var buf bytes.Buffer
if err := s.WriteJSON(&buf); err != nil {
t.Fatal(err)
}
var back Sheet
if err := json.Unmarshal(buf.Bytes(), &back); err != nil {
t.Fatalf("invalid json: %v", err)
}
if back.Frames[0].Box.W != 2 {
t.Errorf("json roundtrip box = %+v", back.Frames[0].Box)
}
}

204
hitbox-tool/main.go Normal file
View File

@@ -0,0 +1,204 @@
// hitbox scans sprite sheet PNGs and writes per-frame collision boxes
// as JSON, using the alpha channel to find each frame's solid pixels.
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool/hitbox"
)
var version = "dev"
const usage = `hitbox - collision box annotator for sprite sheets
Usage:
hitbox scan <sheet.png> [flags] compute per-frame boxes -> JSON
hitbox show <sheet.png> [flags] draw frames + boxes in the terminal
hitbox version
Scan flags:
--cell <WxH> frame size, e.g. 8x8. Default: parsed from the
spritec naming convention <name>_<W>x<H>_<C>x<R>.png;
if neither is given the whole image is one frame.
--threshold <n> alpha 1-255 that counts as solid (default 1)
--shrink <n> contract every box by n px per side (forgiving hits)
--pad <n> expand every box by n px per side
-o <path> write JSON here (default: stdout)
Show flags: --cell, --threshold, plus
--frame <n> only this frame index (default: all)
Boxes are relative to each frame's top-left corner. Frame indices are
row-major (index = row * cols + col), matching spritec sheets.
In game code: hit if (px,py) inside (frameX + box.x, frameY + box.y,
box.w, box.h).
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "scan":
cmdScan(os.Args[2:])
case "show":
cmdShow(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("hitbox", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'hitbox help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
// parseCell resolves the frame size from --cell, or from the spritec
// naming convention (auto-detecting integer upscales: a sheet named
// _8x8_4x1 that is 256x64 px was rendered at scale 8, so cells are 64x64).
func parseCell(spec, path string, imgW, imgH int) (int, int, error) {
if spec != "" {
parts := strings.SplitN(strings.ToLower(spec), "x", 2)
if len(parts) == 2 {
w, err1 := strconv.Atoi(parts[0])
h, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil && w > 0 && h > 0 {
return w, h, nil
}
}
return 0, 0, fmt.Errorf("--cell %q must look like 8x8", spec)
}
if w, h, cols, rows, ok := hitbox.LayoutFromName(path); ok {
if s := hitbox.InferScale(imgW, imgH, w, h, cols, rows); s > 1 {
fmt.Fprintf(os.Stderr, "hitbox: image is %dx the named layout — using %dx%d cells\n", s, w*s, h*s)
return w * s, h * s, nil
}
return w, h, nil
}
return 0, 0, nil // whole image = one frame
}
func cmdScan(args []string) {
fs := flag.NewFlagSet("scan", flag.ExitOnError)
cell := fs.String("cell", "", "frame size WxH")
threshold := fs.Int("threshold", 1, "solid alpha 1-255")
shrink := fs.Int("shrink", 0, "contract boxes n px per side")
pad := fs.Int("pad", 0, "expand boxes n px per side")
out := fs.String("o", "", "output JSON path (default stdout)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("scan takes exactly one PNG file")
}
path := fs.Arg(0)
img, err := hitbox.LoadPNG(path)
if err != nil {
die("%v", err)
}
b := img.Bounds()
cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy())
if err != nil {
die("%v", err)
}
sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, *shrink, *pad)
if err != nil {
die("%v", err)
}
if *out == "" {
if err := sheet.WriteJSON(os.Stdout); err != nil {
die("%v", err)
}
return
}
f, err := os.Create(*out)
if err != nil {
die("%v", err)
}
defer f.Close()
if err := sheet.WriteJSON(f); err != nil {
die("%v", err)
}
solid := 0
for _, fr := range sheet.Frames {
if !fr.Empty {
solid++
}
}
fmt.Printf("%s (%d frames of %dx%d, %d with pixels)\n",
*out, len(sheet.Frames), sheet.CellW, sheet.CellH, solid)
}
func cmdShow(args []string) {
fs := flag.NewFlagSet("show", flag.ExitOnError)
cell := fs.String("cell", "", "frame size WxH")
threshold := fs.Int("threshold", 1, "solid alpha 1-255")
frame := fs.Int("frame", -1, "frame index (default: all)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("show takes exactly one PNG file")
}
path := fs.Arg(0)
img, err := hitbox.LoadPNG(path)
if err != nil {
die("%v", err)
}
b := img.Bounds()
cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy())
if err != nil {
die("%v", err)
}
sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, 0, 0)
if err != nil {
die("%v", err)
}
for _, fr := range sheet.Frames {
if *frame >= 0 && fr.Index != *frame {
continue
}
if fr.Empty {
fmt.Printf("frame %d (col %d, row %d): empty\n\n", fr.Index, fr.Col, fr.Row)
continue
}
fmt.Printf("frame %d (col %d, row %d): box x=%d y=%d w=%d h=%d\n",
fr.Index, fr.Col, fr.Row, fr.Box.X, fr.Box.Y, fr.Box.W, fr.Box.H)
fmt.Print(hitbox.Ascii(img, sheet, fr.Index, *threshold))
fmt.Println()
}
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "hitbox: "+format+"\n", a...)
os.Exit(1)
}

2
mesh-tool/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build/
examples/downloads/

101
mesh-tool/README.md Normal file
View File

@@ -0,0 +1,101 @@
# mesh-tool (`mesht`)
Create, inspect and edit **3D models** from the command line — built so an
agent can *see* a model (ASCII multi-view rendering + measurements),
reason about it, and edit it step by step. Written in Go, zero
dependencies, single static binary.
## Formats
| Format | Read | Write | Notes |
|--------|------|-------|-------|
| OBJ | ✔ | ✔ | multiple named objects, quads/ngons triangulated |
| STL | ✔ | ✔ | binary + ASCII, auto-detected; vertices re-welded on load |
OBJ and STL cover the vast majority of simple editing/printing/game
pipelines and are plain enough to survive round-trips. glTF/GLB is out of
scope for now (a full JSON+buffer+material model; use Blender for that).
Normals, UVs and materials are ignored on read — this tool edits
*geometry*. Viewers recompute normals; STL export writes correct face
normals from the winding.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd mesh-tool
go build -o build/mesht . # or the VS Code task "build mesh-tool"
go test ./...
```
## Commands
```bash
mesht info model.obj # verts/tris, bbox, size, area, volume, watertight?
mesht view model.obj # ASCII render: front, side, top (default)
mesht view model.obj --views iso --width 100 --object wheel
# creation — box|sphere|cylinder|cone|plane|torus
mesht create box --size 2,1,1 --name crate -o crate.obj
mesht create sphere --radius 1 --segments 32 --rings 16 -o ball.stl
# editing (applied in the listed order)
mesht transform m.obj --center # bbox center -> origin
mesht transform m.obj --mirror x # winding auto-corrected
mesht transform m.obj --scale 2 # or --scale 1,2,1
mesht transform m.obj --rotate 0,45,0 # degrees, X then Y then Z
mesht transform m.obj --translate 0,2,0
mesht transform m.obj --fit 10 # largest dimension -> 10 units
mesht transform m.obj --object wheel --scale 1.2 -o out.obj # edit one part
mesht merge body.obj wheels.obj -o car.obj # keeps objects, renames dups
mesht merge a.stl b.stl --flatten -o one.obj # single combined object
mesht convert model.obj -o model.stl # --ascii for text STL
```
`-o` picks the output format from the extension. `transform` overwrites
the input when `-o` is omitted.
## Conventions
- **Axes:** right-handed, **+Y up**, +X right, +Z toward the "front" viewer.
- Primitives are centered on the origin.
- Mirroring / negative scaling automatically flips triangle winding so
surfaces keep facing outward.
- `info` reports **volume only for watertight meshes** — otherwise it
tells you how many boundary/non-manifold edges the mesh has.
## How an agent should edit a model
1. `mesht info m.obj` — learn size, orientation and object names.
2. `mesht view m.obj` — see the shape (front/side/top; add `iso` for depth).
3. Apply **one small transform**, write to a new file.
4. `mesht view` again and compare — verify before continuing.
The ASCII views are z-buffered orthographic renders; brightness = how
much the surface faces the light over the viewer's shoulder, so shape
and curvature read directly from the text. Each view header repeats the
axis legend and model dimensions.
Example (a 32-segment sphere, front view, width 48):
```
front view — X→right Y↑up (seen from +Z)
model 2 x 2 x 2 (XYZ)
==++**####%%%%%%%%%%%%###*
-==++**######%%%%%%%%%%%%%%####*
:-=+++**#######%%%%%%%%%%%%%%%%%%#**
-==++***#######%%%%%%%%%%%%%%%%%%%%##*
.:--+++***########%%%%%%%%%%%%%%%%%%%%%%##*+
```
## Viewing the results as a human
Any of these (Arch/Garuda):
```bash
sudo pacman -S f3d # fast minimal viewer: f3d model.obj
sudo pacman -S blender # full editor
flatpak install org.prusa3d.PrusaSlicer # if you also want to print
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3
mesh-tool/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/mesh-tool
go 1.24

406
mesh-tool/main.go Normal file
View File

@@ -0,0 +1,406 @@
// mesht creates, inspects and edits 3D models (OBJ and STL) from the
// command line, with ASCII multi-view rendering so agents can "see"
// a model before and after editing it.
package main
import (
"flag"
"fmt"
"math"
"os"
"strconv"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/mesh-tool/mesh"
)
var version = "dev"
const usage = `mesht - 3D model tool for agents (OBJ + STL)
Usage:
mesht info <file> stats: size, volume, watertightness
mesht view <file> [flags] ASCII render from several angles
mesht create <primitive> [flags] box|sphere|cylinder|cone|plane|torus
mesht transform <file> [flags] scale/rotate/translate/mirror/center/fit
mesht merge <a> <b> ... -o <out> combine several files into one
mesht convert <in> -o <out> obj <-> stl
mesht version
View flags:
--views front,side,top,iso,back which views to draw (default front,side,top)
--width <n> characters per view (default 64)
--object <name> only draw one object from the file
Create flags (always with -o <out.obj|out.stl>):
box: --size x,y,z (default 1,1,1)
sphere: --radius r --segments n --rings n (default 1, 24, 12)
cylinder: --radius r --height h --segments n (default 0.5, 1, 24)
cone: --radius r --height h --segments n (default 0.5, 1, 24)
plane: --size w,d (default 1,1)
torus: --radius R --tube r --segments n --rings n (default 1, 0.25, 24, 12)
--name <s> object name in the output
Transform flags (applied in this order):
--center move bounding-box center to the origin
--mirror x|y|z mirror across that axis' plane
--scale s | x,y,z uniform or per-axis scale
--rotate x,y,z degrees around X, then Y, then Z
--translate x,y,z move
--fit n uniformly scale so the largest dimension = n
--object <name> only transform the named object (obj files)
-o <out> output file (default: overwrite input)
Common:
--ascii write text STL instead of binary
Formats are picked from file extensions (.obj, .stl).
Axes: +Y is up, +X right, +Z toward the front viewer (right-handed).
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "info":
cmdInfo(os.Args[2:])
case "view":
cmdView(os.Args[2:])
case "create":
cmdCreate(os.Args[2:])
case "transform":
cmdTransform(os.Args[2:])
case "merge":
cmdMerge(os.Args[2:])
case "convert":
cmdConvert(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("mesht", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'mesht help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
func parseVec(s string, uniformOK bool) (mesh.Vec3, error) {
parts := strings.Split(s, ",")
switch len(parts) {
case 1:
if !uniformOK {
return mesh.Vec3{}, fmt.Errorf("%q: want x,y,z", s)
}
v, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
return mesh.Vec3{X: v, Y: v, Z: v}, err
case 3:
var v [3]float64
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
return mesh.Vec3{}, fmt.Errorf("%q: bad number %q", s, p)
}
v[i] = f
}
return mesh.Vec3{X: v[0], Y: v[1], Z: v[2]}, nil
}
return mesh.Vec3{}, fmt.Errorf("%q: want one value or x,y,z", s)
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one file")
}
scene, err := mesh.ReadFile(args[0])
if err != nil {
die("%v", err)
}
merged := scene.Merged()
mn, mx := merged.BBox()
size := mx.Sub(mn)
center := mn.Add(size.Mul(0.5))
fmt.Printf("file: %s\n", args[0])
fmt.Printf("objects: %d\n", len(scene.Meshes))
fmt.Printf("verts: %d\n", scene.TotalVerts())
fmt.Printf("tris: %d\n", scene.TotalTris())
fmt.Printf("bbox min: (%.4g, %.4g, %.4g)\n", mn.X, mn.Y, mn.Z)
fmt.Printf("bbox max: (%.4g, %.4g, %.4g)\n", mx.X, mx.Y, mx.Z)
fmt.Printf("size: %.4g x %.4g x %.4g (X Y Z)\n", size.X, size.Y, size.Z)
fmt.Printf("center: (%.4g, %.4g, %.4g)\n", center.X, center.Y, center.Z)
fmt.Printf("area: %.6g\n", merged.SurfaceArea())
if b, nm := merged.EdgeStats(); b == 0 && nm == 0 {
fmt.Printf("volume: %.6g (closed mesh)\n", merged.Volume())
} else {
fmt.Printf("volume: n/a (open mesh: %d boundary edges, %d non-manifold edges)\n", b, nm)
}
if len(scene.Meshes) > 1 {
fmt.Println("per object:")
for _, m := range scene.Meshes {
closed := "open"
if m.Closed() {
closed = "closed"
}
fmt.Printf(" %-24s %7d verts %7d tris %s\n", m.Name, len(m.Verts), len(m.Tris), closed)
}
}
}
func cmdView(args []string) {
fs := flag.NewFlagSet("view", flag.ExitOnError)
views := fs.String("views", "front,side,top", "comma-separated view list")
width := fs.Int("width", 64, "characters per view")
object := fs.String("object", "", "only draw this object")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("view takes exactly one file")
}
scene, err := mesh.ReadFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
m := scene.Merged()
if *object != "" {
if m = scene.Mesh(*object); m == nil {
die("no object named %q in %s (use 'mesht info' to list)", *object, fs.Arg(0))
}
}
for _, name := range strings.Split(*views, ",") {
name = strings.TrimSpace(strings.ToLower(name))
v, ok := mesh.Views[name]
if !ok {
die("unknown view %q (available: %s)", name, strings.Join(mesh.ViewOrder, ", "))
}
fmt.Println(mesh.RenderASCII(m, v, *width))
}
}
func cmdCreate(args []string) {
if len(args) < 1 {
die("create needs a primitive: box, sphere, cylinder, cone, plane, torus")
}
prim := args[0]
fs := flag.NewFlagSet("create", flag.ExitOnError)
size := fs.String("size", "1,1,1", "box/plane size")
radius := fs.Float64("radius", 0, "radius")
tube := fs.Float64("tube", 0.25, "torus tube radius")
height := fs.Float64("height", 1, "height")
segments := fs.Int("segments", 24, "segments around")
rings := fs.Int("rings", 12, "rings (sphere/torus)")
name := fs.String("name", "", "object name")
out := fs.String("o", "", "output file (.obj or .stl)")
ascii := fs.Bool("ascii", false, "write text STL")
parseInterspersed(fs, args[1:])
if *out == "" {
die("create needs -o <out.obj|out.stl>")
}
var m *mesh.Mesh
switch prim {
case "box":
sz, err := parseVec(*size, true)
if err != nil {
die("--size: %v", err)
}
m = mesh.Box(sz)
case "sphere":
m = mesh.Sphere(defRadius(*radius, 1), *segments, *rings)
case "cylinder":
m = mesh.Cylinder(defRadius(*radius, 0.5), *height, *segments)
case "cone":
m = mesh.Cone(defRadius(*radius, 0.5), *height, *segments)
case "plane":
sz, err := parseVec(*size, true)
if err != nil {
die("--size: %v", err)
}
m = mesh.Plane(sz.X, sz.Z)
case "torus":
m = mesh.Torus(defRadius(*radius, 1), *tube, *segments, *rings)
default:
die("unknown primitive %q", prim)
}
if *name != "" {
m.Name = *name
}
scene := &mesh.Scene{Meshes: []*mesh.Mesh{m}}
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s: %d verts, %d tris)\n", *out, m.Name, len(m.Verts), len(m.Tris))
}
func defRadius(r, def float64) float64 {
if r <= 0 {
return def
}
return r
}
func cmdTransform(args []string) {
fs := flag.NewFlagSet("transform", flag.ExitOnError)
center := fs.Bool("center", false, "move bbox center to origin")
mirror := fs.String("mirror", "", "x|y|z")
scale := fs.String("scale", "", "s or x,y,z")
rotate := fs.String("rotate", "", "degrees x,y,z")
translate := fs.String("translate", "", "x,y,z")
fit := fs.Float64("fit", 0, "scale so the largest dimension equals this")
object := fs.String("object", "", "only transform this object")
out := fs.String("o", "", "output file (default: overwrite input)")
ascii := fs.Bool("ascii", false, "write text STL")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("transform takes exactly one file")
}
in := fs.Arg(0)
scene, err := mesh.ReadFile(in)
if err != nil {
die("%v", err)
}
targets := scene.Meshes
if *object != "" {
m := scene.Mesh(*object)
if m == nil {
die("no object named %q in %s (use 'mesht info' to list)", *object, in)
}
targets = []*mesh.Mesh{m}
}
if *center {
mn, mx := mesh.SceneBBox(targets)
c := mn.Add(mx.Sub(mn).Mul(0.5))
mesh.ApplyAll(targets, mesh.Translate(c.Mul(-1)))
}
if *mirror != "" {
s := mesh.Vec3{X: 1, Y: 1, Z: 1}
switch strings.ToLower(*mirror) {
case "x":
s.X = -1
case "y":
s.Y = -1
case "z":
s.Z = -1
default:
die("--mirror must be x, y or z")
}
mesh.ApplyAll(targets, mesh.ScaleXYZ(s))
}
if *scale != "" {
v, err := parseVec(*scale, true)
if err != nil {
die("--scale: %v", err)
}
mesh.ApplyAll(targets, mesh.ScaleXYZ(v))
}
if *rotate != "" {
v, err := parseVec(*rotate, false)
if err != nil {
die("--rotate: %v", err)
}
rot := mesh.RotateZ(v.Z).Mul(mesh.RotateY(v.Y)).Mul(mesh.RotateX(v.X))
mesh.ApplyAll(targets, rot)
}
if *translate != "" {
v, err := parseVec(*translate, false)
if err != nil {
die("--translate: %v", err)
}
mesh.ApplyAll(targets, mesh.Translate(v))
}
if *fit > 0 {
mn, mx := mesh.SceneBBox(targets)
size := mx.Sub(mn)
longest := math.Max(size.X, math.Max(size.Y, size.Z))
if longest > 0 {
f := *fit / longest
mesh.ApplyAll(targets, mesh.ScaleXYZ(mesh.Vec3{X: f, Y: f, Z: f}))
}
}
if *out == "" {
*out = in
}
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
die("%v", err)
}
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
}
func cmdMerge(args []string) {
fs := flag.NewFlagSet("merge", flag.ExitOnError)
out := fs.String("o", "", "output file")
flatten := fs.Bool("flatten", false, "merge everything into a single object")
ascii := fs.Bool("ascii", false, "write text STL")
parseInterspersed(fs, args)
if fs.NArg() < 2 {
die("merge needs at least two input files")
}
if *out == "" {
die("merge needs -o <out>")
}
scene := &mesh.Scene{}
for _, path := range fs.Args() {
s, err := mesh.ReadFile(path)
if err != nil {
die("%v", err)
}
scene.Append(s)
}
if *flatten {
scene = &mesh.Scene{Meshes: []*mesh.Mesh{scene.Merged()}}
}
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
die("%v", err)
}
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
}
func cmdConvert(args []string) {
fs := flag.NewFlagSet("convert", flag.ExitOnError)
out := fs.String("o", "", "output file")
ascii := fs.Bool("ascii", false, "write text STL")
parseInterspersed(fs, args)
if fs.NArg() != 1 || *out == "" {
die("convert takes one input file and -o <out>")
}
scene, err := mesh.ReadFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
die("%v", err)
}
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "mesht: "+format+"\n", a...)
os.Exit(1)
}

169
mesh-tool/mesh/ascii.go Normal file
View File

@@ -0,0 +1,169 @@
package mesh
import (
"fmt"
"math"
"strings"
)
// View is an orthographic camera basis: Right/Up span the screen plane,
// Toward points from the scene toward the viewer (bigger depth = closer).
type View struct {
Name string
Axes string // human-readable axis legend
Right, Up, Toward Vec3
}
var Views = map[string]View{
"front": {"front", "X→right Y↑up (seen from +Z)", Vec3{1, 0, 0}, Vec3{0, 1, 0}, Vec3{0, 0, 1}},
"back": {"back", "-X→right Y↑up (seen from -Z)", Vec3{-1, 0, 0}, Vec3{0, 1, 0}, Vec3{0, 0, -1}},
"side": {"side", "-Z→right Y↑up (seen from +X)", Vec3{0, 0, -1}, Vec3{0, 1, 0}, Vec3{1, 0, 0}},
"top": {"top", "X→right Z↓down-screen (seen from above, +Y)", Vec3{1, 0, 0}, Vec3{0, 0, -1}, Vec3{0, 1, 0}},
"iso": {"iso", "isometric from (+X +Y +Z)",
Vec3{1, 0, -1}.Norm(), Vec3{-1, 2, -1}.Norm(), Vec3{1, 1, 1}.Norm()},
}
// ViewOrder is the canonical ordering for multi-view output.
var ViewOrder = []string{"front", "side", "top", "iso", "back"}
const shadeRamp = " .:-=+*#%@"
// charAspect compensates terminal cells being ~2x taller than wide.
const charAspect = 0.5
// RenderASCII draws the mesh from the given view into a text block of
// the given character width. Triangles are z-buffer rasterized and
// shaded by how much each face points toward the light (over the
// viewer's shoulder), so curvature and depth read as brightness.
func RenderASCII(m *Mesh, v View, width int) string {
if width < 8 {
width = 8
}
if len(m.Tris) == 0 {
return "(empty mesh)\n"
}
// project all vertices into view space
type pv struct{ x, y, z float64 }
pts := make([]pv, len(m.Verts))
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
for i, w := range m.Verts {
p := pv{w.Dot(v.Right), w.Dot(v.Up), w.Dot(v.Toward)}
pts[i] = p
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
}
spanX, spanY := maxX-minX, maxY-minY
if spanX == 0 {
spanX = 1e-9
}
if spanY == 0 {
spanY = 1e-9
}
height := int(float64(width) * (spanY / spanX) * charAspect)
if height < 1 {
height = 1
}
if height > 4*width {
height = 4 * width
}
sx := float64(width-1) / spanX
sy := float64(height-1) / spanY
depth := make([]float64, width*height)
for i := range depth {
depth[i] = math.Inf(-1)
}
shade := make([]float64, width*height)
for i := range shade {
shade[i] = -1
}
light := v.Toward.Mul(0.8).Add(v.Up.Mul(0.5)).Add(v.Right.Mul(0.3)).Norm()
for ti, t := range m.Tris {
n := m.FaceNormal(ti)
// abs: downloaded models often have mixed winding; treat both
// sides as lit so the silhouette never goes black
lum := 0.15 + 0.85*math.Abs(n.Dot(light))
a, b, c := pts[t[0]], pts[t[1]], pts[t[2]]
ax, ay := (a.x-minX)*sx, (maxY-a.y)*sy
bx, by := (b.x-minX)*sx, (maxY-b.y)*sy
cx, cy := (c.x-minX)*sx, (maxY-c.y)*sy
x0 := int(math.Floor(math.Min(ax, math.Min(bx, cx))))
x1 := int(math.Ceil(math.Max(ax, math.Max(bx, cx))))
y0 := int(math.Floor(math.Min(ay, math.Min(by, cy))))
y1 := int(math.Ceil(math.Max(ay, math.Max(by, cy))))
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if x1 >= width {
x1 = width - 1
}
if y1 >= height {
y1 = height - 1
}
area := (bx-ax)*(cy-ay) - (by-ay)*(cx-ax)
if area == 0 {
continue
}
for py := y0; py <= y1; py++ {
for px := x0; px <= x1; px++ {
fx, fy := float64(px), float64(py)
w0 := (bx-ax)*(fy-ay) - (by-ay)*(fx-ax)
w1 := (cx-bx)*(fy-by) - (cy-by)*(fx-bx)
w2 := (ax-cx)*(fy-cy) - (ay-cy)*(fx-cx)
if !sameSide(w0, w1, w2, area) {
continue
}
// barycentric depth: w2 tracks b, w0 tracks c
l1 := w2 / area
l2 := w0 / area
l0 := 1 - l1 - l2
z := l0*a.z + l1*b.z + l2*c.z
idx := py*width + px
if z > depth[idx] {
depth[idx] = z
shade[idx] = lum
}
}
}
}
var sb strings.Builder
mn, mx := m.BBox()
size := mx.Sub(mn)
fmt.Fprintf(&sb, "%s view — %s\nmodel %.3g x %.3g x %.3g (XYZ)\n",
v.Name, v.Axes, size.X, size.Y, size.Z)
ramp := []rune(shadeRamp)
for py := 0; py < height; py++ {
for px := 0; px < width; px++ {
s := shade[py*width+px]
if s < 0 {
sb.WriteByte(' ')
continue
}
i := int(s * float64(len(ramp)-1))
if i >= len(ramp) {
i = len(ramp) - 1
}
sb.WriteRune(ramp[i])
}
sb.WriteByte('\n')
}
return sb.String()
}
func sameSide(w0, w1, w2, area float64) bool {
if area > 0 {
return w0 >= 0 && w1 >= 0 && w2 >= 0
}
return w0 <= 0 && w1 <= 0 && w2 <= 0
}

59
mesh-tool/mesh/measure.go Normal file
View File

@@ -0,0 +1,59 @@
package mesh
import "math"
// BBox returns the axis-aligned bounding box of the mesh.
func (m *Mesh) BBox() (min, max Vec3) {
if len(m.Verts) == 0 {
return Vec3{}, Vec3{}
}
min, max = m.Verts[0], m.Verts[0]
for _, v := range m.Verts[1:] {
min = min.Min(v)
max = max.Max(v)
}
return
}
// SurfaceArea sums the area of all triangles.
func (m *Mesh) SurfaceArea() float64 {
sum := 0.0
for _, t := range m.Tris {
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
sum += b.Sub(a).Cross(c.Sub(a)).Len() / 2
}
return sum
}
// SignedVolume computes the enclosed volume via the divergence theorem.
// Only meaningful for closed meshes; positive when windings face outward.
func (m *Mesh) SignedVolume() float64 {
sum := 0.0
for _, t := range m.Tris {
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
sum += a.Dot(b.Cross(c))
}
return sum / 6
}
// Volume is the absolute enclosed volume.
func (m *Mesh) Volume() float64 { return math.Abs(m.SignedVolume()) }
// SceneBBox returns the bounding box over the given meshes.
func SceneBBox(meshes []*Mesh) (min, max Vec3) {
first := true
for _, m := range meshes {
if len(m.Verts) == 0 {
continue
}
mn, mx := m.BBox()
if first {
min, max = mn, mx
first = false
} else {
min = min.Min(mn)
max = max.Max(mx)
}
}
return
}

129
mesh-tool/mesh/mesh.go Normal file
View File

@@ -0,0 +1,129 @@
package mesh
import "fmt"
// Triangle indexes three vertices, counter-clockwise seen from outside.
type Triangle [3]int
// Mesh is one named object: a triangle soup over a shared vertex list.
type Mesh struct {
Name string
Verts []Vec3
Tris []Triangle
}
// Scene is an ordered list of meshes, matching OBJ objects. STL files
// load as a single-mesh scene.
type Scene struct {
Meshes []*Mesh
}
func (s *Scene) TotalVerts() int {
n := 0
for _, m := range s.Meshes {
n += len(m.Verts)
}
return n
}
func (s *Scene) TotalTris() int {
n := 0
for _, m := range s.Meshes {
n += len(m.Tris)
}
return n
}
// Mesh returns the named mesh, or nil.
func (s *Scene) Mesh(name string) *Mesh {
for _, m := range s.Meshes {
if m.Name == name {
return m
}
}
return nil
}
// Merged flattens the scene into a single mesh (copies data).
func (s *Scene) Merged() *Mesh {
out := &Mesh{Name: "merged"}
for _, m := range s.Meshes {
off := len(out.Verts)
out.Verts = append(out.Verts, m.Verts...)
for _, t := range m.Tris {
out.Tris = append(out.Tris, Triangle{t[0] + off, t[1] + off, t[2] + off})
}
}
if len(s.Meshes) == 1 {
out.Name = s.Meshes[0].Name
}
return out
}
// Append adds meshes from another scene, de-duplicating names by
// appending _2, _3, ...
func (s *Scene) Append(other *Scene) {
taken := map[string]bool{}
for _, m := range s.Meshes {
taken[m.Name] = true
}
for _, m := range other.Meshes {
name := m.Name
for i := 2; taken[name]; i++ {
name = fmt.Sprintf("%s_%d", m.Name, i)
}
m.Name = name
taken[name] = true
s.Meshes = append(s.Meshes, m)
}
}
// FaceNormal returns the (unit) normal of triangle i.
func (m *Mesh) FaceNormal(i int) Vec3 {
t := m.Tris[i]
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
return b.Sub(a).Cross(c.Sub(a)).Norm()
}
// FlipWinding reverses the orientation of every triangle.
func (m *Mesh) FlipWinding() {
for i, t := range m.Tris {
m.Tris[i] = Triangle{t[0], t[2], t[1]}
}
}
type edge struct{ a, b int }
func normEdge(a, b int) edge {
if a > b {
a, b = b, a
}
return edge{a, b}
}
// EdgeStats classifies the mesh topology: boundary edges belong to one
// triangle, manifold edges to two, anything more is non-manifold. A
// closed (watertight) mesh has zero boundary and zero non-manifold edges.
func (m *Mesh) EdgeStats() (boundary, nonManifold int) {
count := map[edge]int{}
for _, t := range m.Tris {
count[normEdge(t[0], t[1])]++
count[normEdge(t[1], t[2])]++
count[normEdge(t[2], t[0])]++
}
for _, n := range count {
switch {
case n == 1:
boundary++
case n > 2:
nonManifold++
}
}
return
}
// Closed reports whether the mesh is watertight.
func (m *Mesh) Closed() bool {
b, nm := m.EdgeStats()
return b == 0 && nm == 0
}

184
mesh-tool/mesh/mesh_test.go Normal file
View File

@@ -0,0 +1,184 @@
package mesh
import (
"bytes"
"math"
"strings"
"testing"
)
func almost(t *testing.T, name string, got, want, tol float64) {
t.Helper()
if math.Abs(got-want) > tol {
t.Errorf("%s = %g, want %g (±%g)", name, got, want, tol)
}
}
func TestPrimitiveVolumes(t *testing.T) {
box := Box(Vec3{1, 2, 3})
almost(t, "box volume", box.SignedVolume(), 6, 1e-9)
almost(t, "box area", box.SurfaceArea(), 22, 1e-9)
if !box.Closed() {
t.Error("box should be watertight")
}
sph := Sphere(1, 64, 32)
almost(t, "sphere volume", sph.SignedVolume(), 4*math.Pi/3, 0.07)
almost(t, "sphere area", sph.SurfaceArea(), 4*math.Pi, 0.15)
if !sph.Closed() {
t.Error("sphere should be watertight")
}
cyl := Cylinder(0.5, 2, 64)
almost(t, "cylinder volume", cyl.SignedVolume(), math.Pi*0.25*2, 0.01)
if !cyl.Closed() {
t.Error("cylinder should be watertight")
}
cone := Cone(1, 3, 64)
almost(t, "cone volume", cone.SignedVolume(), math.Pi/3*3, 0.02)
if !cone.Closed() {
t.Error("cone should be watertight")
}
tor := Torus(2, 0.5, 64, 32)
almost(t, "torus volume", tor.SignedVolume(), 2*math.Pi*math.Pi*2*0.25, 0.25)
if !tor.Closed() {
t.Error("torus should be watertight")
}
}
func TestBBoxAndMeasure(t *testing.T) {
box := Box(Vec3{2, 4, 6})
mn, mx := box.BBox()
if mn != (Vec3{-1, -2, -3}) || mx != (Vec3{1, 2, 3}) {
t.Errorf("bbox = %v..%v", mn, mx)
}
}
func TestTransformMirrorKeepsVolumePositive(t *testing.T) {
box := Box(Vec3{1, 1, 1})
box.Apply(ScaleXYZ(Vec3{-1, 1, 1}))
almost(t, "mirrored box volume", box.SignedVolume(), 1, 1e-9)
box.Apply(RotateY(45).Mul(RotateX(30)))
almost(t, "rotated box volume", box.SignedVolume(), 1, 1e-9)
box.Apply(Translate(Vec3{10, -5, 3}))
almost(t, "translated box volume", box.SignedVolume(), 1, 1e-6)
}
func TestOBJRoundTrip(t *testing.T) {
scene := &Scene{Meshes: []*Mesh{Box(Vec3{1, 2, 3}), Sphere(1, 8, 4)}}
scene.Meshes[0].Name = "crate"
scene.Meshes[1].Name = "ball"
var buf bytes.Buffer
if err := WriteOBJ(&buf, scene); err != nil {
t.Fatal(err)
}
back, err := ReadOBJ(&buf)
if err != nil {
t.Fatal(err)
}
if len(back.Meshes) != 2 {
t.Fatalf("got %d meshes, want 2", len(back.Meshes))
}
if back.Meshes[0].Name != "crate" || back.Meshes[1].Name != "ball" {
t.Errorf("names = %q, %q", back.Meshes[0].Name, back.Meshes[1].Name)
}
almost(t, "roundtrip crate volume", back.Meshes[0].SignedVolume(), 6, 1e-9)
if !back.Meshes[1].Closed() {
t.Error("roundtripped sphere should stay watertight")
}
}
func TestOBJQuadsAndNegativeIndices(t *testing.T) {
src := `
v 0 0 0
v 1 0 0
v 1 1 0
v 0 1 0
f 1 2 3 4
f -4 -3 -2
`
s, err := ReadOBJ(strings.NewReader(src))
if err != nil {
t.Fatal(err)
}
if got := s.TotalTris(); got != 3 {
t.Errorf("tris = %d, want 3 (quad fan + one negative-index tri)", got)
}
}
func TestSTLRoundTrips(t *testing.T) {
box := Box(Vec3{1, 2, 3})
scene := &Scene{Meshes: []*Mesh{box}}
var bin bytes.Buffer
if err := WriteSTLBinary(&bin, scene); err != nil {
t.Fatal(err)
}
back, err := ReadSTL(bytes.NewReader(bin.Bytes()))
if err != nil {
t.Fatal(err)
}
m := back.Meshes[0]
if len(m.Verts) != 8 {
t.Errorf("binary stl weld: %d verts, want 8", len(m.Verts))
}
almost(t, "binary stl volume", m.SignedVolume(), 6, 1e-6)
var asc bytes.Buffer
if err := WriteSTLAscii(&asc, scene); err != nil {
t.Fatal(err)
}
back2, err := ReadSTL(bytes.NewReader(asc.Bytes()))
if err != nil {
t.Fatal(err)
}
almost(t, "ascii stl volume", back2.Meshes[0].SignedVolume(), 6, 1e-6)
if !back2.Meshes[0].Closed() {
t.Error("ascii stl roundtrip should stay watertight")
}
}
func TestSceneAppendRenames(t *testing.T) {
a := &Scene{Meshes: []*Mesh{Box(Vec3{1, 1, 1})}}
b := &Scene{Meshes: []*Mesh{Box(Vec3{2, 2, 2})}}
a.Append(b)
if a.Meshes[0].Name == a.Meshes[1].Name {
t.Errorf("duplicate names after append: %q", a.Meshes[0].Name)
}
}
func TestRenderASCII(t *testing.T) {
sph := Sphere(1, 32, 16)
out := RenderASCII(sph, Views["front"], 40)
if !strings.Contains(out, "front view") {
t.Errorf("missing header:\n%s", out)
}
ink := 0
for _, r := range out {
if strings.ContainsRune(shadeRamp[1:], r) {
ink++
}
}
if ink < 100 {
t.Errorf("sphere render suspiciously empty (%d shaded cells):\n%s", ink, out)
}
// a sphere should be roughly as tall as wide after aspect correction
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
rows := len(lines) - 2 // minus the two header lines
if rows < 15 || rows > 25 {
t.Errorf("40-wide sphere should be ~20 rows, got %d", rows)
}
}
func TestEdgeStatsOpenMesh(t *testing.T) {
p := Plane(1, 1)
if p.Closed() {
t.Error("plane must not be watertight")
}
b, nm := p.EdgeStats()
if b != 4 || nm != 0 {
t.Errorf("plane edge stats = %d boundary, %d non-manifold; want 4, 0", b, nm)
}
}

203
mesh-tool/mesh/obj.go Normal file
View File

@@ -0,0 +1,203 @@
package mesh
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
)
// ReadOBJ parses a Wavefront OBJ file. Vertices (v), objects/groups
// (o/g) and faces (f) are honored; polygons are fan-triangulated;
// normals, texture coords and materials are ignored (they are
// recomputed or irrelevant for geometry editing).
func ReadOBJ(r io.Reader) (*Scene, error) {
var verts []Vec3
type objFaces struct {
name string
tris []Triangle // indices into the global vert list
}
objs := []*objFaces{}
current := func() *objFaces {
if len(objs) == 0 {
objs = append(objs, &objFaces{name: "default"})
}
return objs[len(objs)-1]
}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
lineNo := 0
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
switch fields[0] {
case "v":
if len(fields) < 4 {
return nil, fmt.Errorf("obj line %d: vertex needs x y z", lineNo)
}
var v Vec3
var err error
if v.X, err = strconv.ParseFloat(fields[1], 64); err == nil {
if v.Y, err = strconv.ParseFloat(fields[2], 64); err == nil {
v.Z, err = strconv.ParseFloat(fields[3], 64)
}
}
if err != nil {
return nil, fmt.Errorf("obj line %d: bad vertex: %v", lineNo, err)
}
verts = append(verts, v)
case "o", "g":
name := "unnamed"
if len(fields) > 1 {
name = strings.Join(fields[1:], " ")
}
// only open a new object if the current one has faces
if len(objs) > 0 && len(objs[len(objs)-1].tris) == 0 {
objs[len(objs)-1].name = name
} else {
objs = append(objs, &objFaces{name: name})
}
case "f":
if len(fields) < 4 {
return nil, fmt.Errorf("obj line %d: face needs at least 3 vertices", lineNo)
}
idx := make([]int, 0, len(fields)-1)
for _, f := range fields[1:] {
// "v", "v/vt", "v//vn", "v/vt/vn" — we only need v
vs := strings.SplitN(f, "/", 2)[0]
i, err := strconv.Atoi(vs)
if err != nil {
return nil, fmt.Errorf("obj line %d: bad face index %q", lineNo, f)
}
if i < 0 {
i = len(verts) + i // negative = relative to current count
} else {
i-- // obj is 1-based
}
if i < 0 || i >= len(verts) {
return nil, fmt.Errorf("obj line %d: face index %q out of range (have %d vertices)", lineNo, f, len(verts))
}
idx = append(idx, i)
}
o := current()
for k := 1; k+1 < len(idx); k++ { // fan triangulation
o.tris = append(o.tris, Triangle{idx[0], idx[k], idx[k+1]})
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
// Compact the global vertex list into per-mesh local lists.
scene := &Scene{}
for _, o := range objs {
if len(o.tris) == 0 {
continue
}
m := &Mesh{Name: o.name}
remap := map[int]int{}
for _, t := range o.tris {
var lt Triangle
for k, gi := range t {
li, ok := remap[gi]
if !ok {
li = len(m.Verts)
m.Verts = append(m.Verts, verts[gi])
remap[gi] = li
}
lt[k] = li
}
m.Tris = append(m.Tris, lt)
}
scene.Meshes = append(scene.Meshes, m)
}
if len(scene.Meshes) == 0 {
return nil, fmt.Errorf("obj contains no faces")
}
return scene, nil
}
// WriteOBJ writes the scene as OBJ, one "o" object per mesh.
func WriteOBJ(w io.Writer, s *Scene) error {
bw := bufio.NewWriter(w)
fmt.Fprintln(bw, "# exported by mesht (agent-tools)")
offset := 1 // obj indices are global and 1-based
for _, m := range s.Meshes {
fmt.Fprintf(bw, "o %s\n", m.Name)
for _, v := range m.Verts {
fmt.Fprintf(bw, "v %g %g %g\n", v.X, v.Y, v.Z)
}
for _, t := range m.Tris {
fmt.Fprintf(bw, "f %d %d %d\n", t[0]+offset, t[1]+offset, t[2]+offset)
}
offset += len(m.Verts)
}
return bw.Flush()
}
// ReadFile loads a scene, picking the format from the file extension
// (.obj, .stl).
func ReadFile(path string) (*Scene, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
switch ext(path) {
case "obj":
s, err := ReadOBJ(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return s, nil
case "stl":
s, err := ReadSTL(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return s, nil
}
return nil, fmt.Errorf("%s: unsupported format (use .obj or .stl)", path)
}
// WriteFile saves a scene, picking the format from the file extension.
// asciiSTL selects text STL instead of the default binary.
func WriteFile(path string, s *Scene, asciiSTL bool) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
switch ext(path) {
case "obj":
err = WriteOBJ(f, s)
case "stl":
if asciiSTL {
err = WriteSTLAscii(f, s)
} else {
err = WriteSTLBinary(f, s)
}
default:
err = fmt.Errorf("unsupported output format (use .obj or .stl)")
}
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return f.Close()
}
func ext(path string) string {
i := strings.LastIndex(path, ".")
if i < 0 {
return ""
}
return strings.ToLower(path[i+1:])
}

View File

@@ -0,0 +1,179 @@
package mesh
import "math"
// All primitives are centered at the origin with +Y up and get outward
// (counter-clockwise) winding; ensureOutward fixes the global
// orientation via the signed volume as a safety net.
func ensureOutward(m *Mesh) *Mesh {
if m.SignedVolume() < 0 {
m.FlipWinding()
}
return m
}
// Box builds an axis-aligned box of the given size.
func Box(size Vec3) *Mesh {
x, y, z := size.X/2, size.Y/2, size.Z/2
m := &Mesh{
Name: "box",
Verts: []Vec3{
{-x, -y, -z}, {x, -y, -z}, {x, y, -z}, {-x, y, -z}, // back (z-)
{-x, -y, z}, {x, -y, z}, {x, y, z}, {-x, y, z}, // front (z+)
},
}
quads := [][4]int{
{0, 3, 2, 1}, // back
{4, 5, 6, 7}, // front
{0, 1, 5, 4}, // bottom
{2, 3, 7, 6}, // top
{1, 2, 6, 5}, // right
{0, 4, 7, 3}, // left
}
for _, q := range quads {
m.Tris = append(m.Tris, Triangle{q[0], q[1], q[2]}, Triangle{q[0], q[2], q[3]})
}
return ensureOutward(m)
}
// Plane builds a flat rectangle in the XZ plane (an open mesh).
func Plane(w, d float64) *Mesh {
x, z := w/2, d/2
return &Mesh{
Name: "plane",
Verts: []Vec3{{-x, 0, -z}, {x, 0, -z}, {x, 0, z}, {-x, 0, z}},
Tris: []Triangle{{0, 2, 1}, {0, 3, 2}}, // +Y facing
}
}
// Sphere builds a UV sphere. segments = around the equator (>= 3),
// rings = from pole to pole (>= 2).
func Sphere(r float64, segments, rings int) *Mesh {
if segments < 3 {
segments = 3
}
if rings < 2 {
rings = 2
}
m := &Mesh{Name: "sphere"}
top := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, r, 0})
// interior rings, top to bottom
ringStart := make([]int, rings)
for i := 1; i < rings; i++ {
theta := math.Pi * float64(i) / float64(rings)
y := r * math.Cos(theta)
rad := r * math.Sin(theta)
ringStart[i] = len(m.Verts)
for j := 0; j < segments; j++ {
phi := 2 * math.Pi * float64(j) / float64(segments)
m.Verts = append(m.Verts, Vec3{rad * math.Cos(phi), y, rad * math.Sin(phi)})
}
}
bottom := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, -r, 0})
at := func(ring, seg int) int { return ringStart[ring] + seg%segments }
for j := 0; j < segments; j++ {
m.Tris = append(m.Tris, Triangle{top, at(1, j), at(1, j+1)}) // top cap
m.Tris = append(m.Tris, Triangle{bottom, at(rings-1, j+1), at(rings-1, j)})
}
for i := 1; i < rings-1; i++ {
for j := 0; j < segments; j++ {
a, b := at(i, j), at(i, j+1)
c, d := at(i+1, j+1), at(i+1, j)
m.Tris = append(m.Tris, Triangle{a, b, c}, Triangle{a, c, d})
}
}
return ensureOutward(m)
}
// Cylinder builds a closed cylinder of height h around the Y axis.
func Cylinder(r, h float64, segments int) *Mesh {
if segments < 3 {
segments = 3
}
m := &Mesh{Name: "cylinder"}
y := h / 2
topC := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, y, 0})
botC := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, -y, 0})
topStart := len(m.Verts)
for j := 0; j < segments; j++ {
phi := 2 * math.Pi * float64(j) / float64(segments)
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), y, r * math.Sin(phi)})
}
botStart := len(m.Verts)
for j := 0; j < segments; j++ {
phi := 2 * math.Pi * float64(j) / float64(segments)
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), -y, r * math.Sin(phi)})
}
t := func(j int) int { return topStart + j%segments }
b := func(j int) int { return botStart + j%segments }
for j := 0; j < segments; j++ {
m.Tris = append(m.Tris,
Triangle{topC, t(j + 1), t(j)}, // top cap
Triangle{botC, b(j), b(j + 1)}, // bottom cap
Triangle{t(j), t(j + 1), b(j + 1)}, // side
Triangle{t(j), b(j + 1), b(j)}, // side
)
}
return ensureOutward(m)
}
// Cone builds a closed cone with its base at -h/2 and apex at +h/2.
func Cone(r, h float64, segments int) *Mesh {
if segments < 3 {
segments = 3
}
m := &Mesh{Name: "cone"}
apex := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, h / 2, 0})
baseC := len(m.Verts)
m.Verts = append(m.Verts, Vec3{0, -h / 2, 0})
start := len(m.Verts)
for j := 0; j < segments; j++ {
phi := 2 * math.Pi * float64(j) / float64(segments)
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), -h / 2, r * math.Sin(phi)})
}
at := func(j int) int { return start + j%segments }
for j := 0; j < segments; j++ {
m.Tris = append(m.Tris,
Triangle{apex, at(j + 1), at(j)},
Triangle{baseC, at(j), at(j + 1)},
)
}
return ensureOutward(m)
}
// Torus builds a torus around the Y axis: ring radius R (center of tube
// to center of torus) and tube radius r.
func Torus(R, r float64, segments, rings int) *Mesh {
if segments < 3 {
segments = 3
}
if rings < 3 {
rings = 3
}
m := &Mesh{Name: "torus"}
for i := 0; i < segments; i++ { // around the main ring
phi := 2 * math.Pi * float64(i) / float64(segments)
cx, cz := math.Cos(phi), math.Sin(phi)
for j := 0; j < rings; j++ { // around the tube
theta := 2 * math.Pi * float64(j) / float64(rings)
rad := R + r*math.Cos(theta)
m.Verts = append(m.Verts, Vec3{rad * cx, r * math.Sin(theta), rad * cz})
}
}
at := func(i, j int) int { return (i%segments)*rings + j%rings }
for i := 0; i < segments; i++ {
for j := 0; j < rings; j++ {
a, b := at(i, j), at(i+1, j)
c, d := at(i+1, j+1), at(i, j+1)
m.Tris = append(m.Tris, Triangle{a, b, c}, Triangle{a, c, d})
}
}
return ensureOutward(m)
}

182
mesh-tool/mesh/stl.go Normal file
View File

@@ -0,0 +1,182 @@
package mesh
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"strconv"
"strings"
)
// ReadSTL reads binary or ASCII STL (auto-detected). STL stores loose
// triangles, so identical vertices are welded back together to recover
// connectivity (needed for watertight checks and sane OBJ export).
func ReadSTL(r io.Reader) (*Scene, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
if len(data) >= 84 {
n := binary.LittleEndian.Uint32(data[80:84])
if int(84+50*n) == len(data) {
return readSTLBinary(data)
}
}
if bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte("solid")) {
return readSTLAscii(data)
}
return nil, fmt.Errorf("not a valid STL file (neither binary layout nor 'solid ...' text)")
}
type welder struct {
mesh *Mesh
index map[Vec3]int
}
func newWelder(name string) *welder {
return &welder{mesh: &Mesh{Name: name}, index: map[Vec3]int{}}
}
func (w *welder) add(a, b, c Vec3) {
var t Triangle
for i, v := range [3]Vec3{a, b, c} {
idx, ok := w.index[v]
if !ok {
idx = len(w.mesh.Verts)
w.mesh.Verts = append(w.mesh.Verts, v)
w.index[v] = idx
}
t[i] = idx
}
if t[0] == t[1] || t[1] == t[2] || t[2] == t[0] {
return // degenerate
}
w.mesh.Tris = append(w.mesh.Tris, t)
}
func readSTLBinary(data []byte) (*Scene, error) {
n := int(binary.LittleEndian.Uint32(data[80:84]))
w := newWelder("stl")
off := 84
f32 := func(o int) float64 {
return float64(math.Float32frombits(binary.LittleEndian.Uint32(data[o : o+4])))
}
for i := 0; i < n; i++ {
// 12 bytes normal (ignored), 3 * 12 bytes vertices, 2 bytes attrs
var v [3]Vec3
for k := 0; k < 3; k++ {
base := off + 12 + k*12
v[k] = Vec3{f32(base), f32(base + 4), f32(base + 8)}
}
w.add(v[0], v[1], v[2])
off += 50
}
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
}
func readSTLAscii(data []byte) (*Scene, error) {
name := "stl"
w := newWelder(name)
var cur []Vec3
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
lineNo := 0
for sc.Scan() {
lineNo++
fields := strings.Fields(sc.Text())
if len(fields) == 0 {
continue
}
switch fields[0] {
case "solid":
if len(fields) > 1 {
w.mesh.Name = fields[1]
}
case "vertex":
if len(fields) < 4 {
return nil, fmt.Errorf("stl line %d: vertex needs x y z", lineNo)
}
var v Vec3
var err error
if v.X, err = strconv.ParseFloat(fields[1], 64); err == nil {
if v.Y, err = strconv.ParseFloat(fields[2], 64); err == nil {
v.Z, err = strconv.ParseFloat(fields[3], 64)
}
}
if err != nil {
return nil, fmt.Errorf("stl line %d: bad vertex: %v", lineNo, err)
}
cur = append(cur, v)
case "endfacet":
if len(cur) != 3 {
return nil, fmt.Errorf("stl line %d: facet has %d vertices, want 3", lineNo, len(cur))
}
w.add(cur[0], cur[1], cur[2])
cur = cur[:0]
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if len(w.mesh.Tris) == 0 {
return nil, fmt.Errorf("stl contains no triangles")
}
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
}
// WriteSTLBinary writes the whole scene as one binary STL solid
// (STL has no concept of multiple named objects).
func WriteSTLBinary(w io.Writer, s *Scene) error {
m := s.Merged()
bw := bufio.NewWriter(w)
header := make([]byte, 80)
copy(header, []byte("exported by mesht (agent-tools)"))
bw.Write(header)
binary.Write(bw, binary.LittleEndian, uint32(len(m.Tris)))
buf := make([]byte, 50)
for i, t := range m.Tris {
n := m.FaceNormal(i)
le := binary.LittleEndian
le.PutUint32(buf[0:], math.Float32bits(float32(n.X)))
le.PutUint32(buf[4:], math.Float32bits(float32(n.Y)))
le.PutUint32(buf[8:], math.Float32bits(float32(n.Z)))
for k := 0; k < 3; k++ {
v := m.Verts[t[k]]
le.PutUint32(buf[12+k*12:], math.Float32bits(float32(v.X)))
le.PutUint32(buf[16+k*12:], math.Float32bits(float32(v.Y)))
le.PutUint32(buf[20+k*12:], math.Float32bits(float32(v.Z)))
}
buf[48], buf[49] = 0, 0
bw.Write(buf)
}
return bw.Flush()
}
// WriteSTLAscii writes the scene as a text STL solid.
func WriteSTLAscii(w io.Writer, s *Scene) error {
m := s.Merged()
bw := bufio.NewWriter(w)
fmt.Fprintf(bw, "solid %s\n", sanitizeToken(m.Name))
for i, t := range m.Tris {
n := m.FaceNormal(i)
fmt.Fprintf(bw, " facet normal %g %g %g\n outer loop\n", n.X, n.Y, n.Z)
for k := 0; k < 3; k++ {
v := m.Verts[t[k]]
fmt.Fprintf(bw, " vertex %g %g %g\n", v.X, v.Y, v.Z)
}
fmt.Fprintf(bw, " endloop\n endfacet\n")
}
fmt.Fprintf(bw, "endsolid %s\n", sanitizeToken(m.Name))
return bw.Flush()
}
func sanitizeToken(s string) string {
s = strings.ReplaceAll(strings.TrimSpace(s), " ", "_")
if s == "" {
return "mesh"
}
return s
}

View File

@@ -0,0 +1,20 @@
package mesh
// Apply transforms every vertex by mat. Mirroring transforms (negative
// determinant) flip triangle winding, so it is corrected here to keep
// normals pointing the same way relative to the surface.
func (m *Mesh) Apply(mat Mat4) {
for i := range m.Verts {
m.Verts[i] = mat.Apply(m.Verts[i])
}
if mat.Det3() < 0 {
m.FlipWinding()
}
}
// ApplyAll transforms a set of meshes.
func ApplyAll(meshes []*Mesh, mat Mat4) {
for _, m := range meshes {
m.Apply(mat)
}
}

122
mesh-tool/mesh/vec.go Normal file
View File

@@ -0,0 +1,122 @@
package mesh
import "math"
// Vec3 is a point or direction in 3D space.
type Vec3 struct{ X, Y, Z float64 }
func (a Vec3) Add(b Vec3) Vec3 { return Vec3{a.X + b.X, a.Y + b.Y, a.Z + b.Z} }
func (a Vec3) Sub(b Vec3) Vec3 { return Vec3{a.X - b.X, a.Y - b.Y, a.Z - b.Z} }
func (a Vec3) Mul(s float64) Vec3 { return Vec3{a.X * s, a.Y * s, a.Z * s} }
func (a Vec3) Dot(b Vec3) float64 { return a.X*b.X + a.Y*b.Y + a.Z*b.Z }
func (a Vec3) Len() float64 { return math.Sqrt(a.Dot(a)) }
func (a Vec3) Cross(b Vec3) Vec3 {
return Vec3{
a.Y*b.Z - a.Z*b.Y,
a.Z*b.X - a.X*b.Z,
a.X*b.Y - a.Y*b.X,
}
}
// Norm returns the unit vector, or the zero vector for zero-length input.
func (a Vec3) Norm() Vec3 {
l := a.Len()
if l == 0 {
return Vec3{}
}
return a.Mul(1 / l)
}
// Min/Max return the component-wise minimum/maximum.
func (a Vec3) Min(b Vec3) Vec3 {
return Vec3{math.Min(a.X, b.X), math.Min(a.Y, b.Y), math.Min(a.Z, b.Z)}
}
func (a Vec3) Max(b Vec3) Vec3 {
return Vec3{math.Max(a.X, b.X), math.Max(a.Y, b.Y), math.Max(a.Z, b.Z)}
}
// Mat4 is a row-major 4x4 transform matrix.
type Mat4 [16]float64
func Identity() Mat4 {
return Mat4{
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
}
}
// Mul returns m * n (n is applied first when transforming points).
func (m Mat4) Mul(n Mat4) Mat4 {
var r Mat4
for row := 0; row < 4; row++ {
for col := 0; col < 4; col++ {
sum := 0.0
for k := 0; k < 4; k++ {
sum += m[row*4+k] * n[k*4+col]
}
r[row*4+col] = sum
}
}
return r
}
// Apply transforms a point (w = 1).
func (m Mat4) Apply(v Vec3) Vec3 {
return Vec3{
m[0]*v.X + m[1]*v.Y + m[2]*v.Z + m[3],
m[4]*v.X + m[5]*v.Y + m[6]*v.Z + m[7],
m[8]*v.X + m[9]*v.Y + m[10]*v.Z + m[11],
}
}
// Det3 is the determinant of the upper-left 3x3. Negative means the
// transform mirrors space, which flips triangle winding.
func (m Mat4) Det3() float64 {
return m[0]*(m[5]*m[10]-m[6]*m[9]) -
m[1]*(m[4]*m[10]-m[6]*m[8]) +
m[2]*(m[4]*m[9]-m[5]*m[8])
}
func Translate(t Vec3) Mat4 {
m := Identity()
m[3], m[7], m[11] = t.X, t.Y, t.Z
return m
}
func ScaleXYZ(s Vec3) Mat4 {
m := Identity()
m[0], m[5], m[10] = s.X, s.Y, s.Z
return m
}
func RotateX(deg float64) Mat4 {
s, c := math.Sincos(deg * math.Pi / 180)
return Mat4{
1, 0, 0, 0,
0, c, -s, 0,
0, s, c, 0,
0, 0, 0, 1,
}
}
func RotateY(deg float64) Mat4 {
s, c := math.Sincos(deg * math.Pi / 180)
return Mat4{
c, 0, s, 0,
0, 1, 0, 0,
-s, 0, c, 0,
0, 0, 0, 1,
}
}
func RotateZ(deg float64) Mat4 {
s, c := math.Sincos(deg * math.Pi / 180)
return Mat4{
c, -s, 0, 0,
s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
}
}

62
notifyr/README.md Normal file
View File

@@ -0,0 +1,62 @@
# notifyr — ntfy client for agents
Send and **read** notifications on the homelab's ntfy bus
(`ntfy.brasse-pc.eu`) with one stable command prefix, so any agent can
alert a human — and check what the infrastructure has been complaining
about — without hand-rolled `curl` loops that each need a fresh
approval. Closes the `PushNotification` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.3.
## Usage
```
notifyr send --msg "text" [--topic T] [--title X]
[--priority min|low|default|high|urgent] [--tags a,b]
notifyr read [--topic T] [--since 10m|2h|all] [--limit N]
notifyr topics # known topics + which one is the default
notifyr version
```
Examples:
```bash
notifyr send --topic ci-fel --title "Build failed" \
--msg "agent-tools arm64 test: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # what has alerted lately?
notifyr read --limit 5 # newest 5 on the default topic
```
`read` uses ntfy's poll mode (`/json?poll=1&since=…`) and prints one
greppable line per message:
```
2026-08-07 00:29:40 [high] (Build failed) agent-tools arm64 test: FAIL #warning
```
Exit codes: 0 ok, 1 error (bad config, server unreachable, invalid
priority…). `read` with zero messages is **not** an error.
## Config
`~/.config/notifyr/config.json`, created with homelab defaults on the
first run (`NOTIFYR_CONFIG` overrides the path):
```json
{
"server": "https://ntfy.brasse-pc.eu",
"token": "",
"default_topic": "claude",
"topics": { "ci-fel": "failed Gitea Actions builds", "…": "…" }
}
```
`topics` is informational — it feeds `notifyr topics` so an agent can
pick the right bus without reading infra-Doc first. Edit freely.
## Build & test
```bash
go test ./...
go build -o build/notifyr .
```

3
notifyr/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/notifyr
go 1.24

132
notifyr/main.go Normal file
View File

@@ -0,0 +1,132 @@
// notifyr sends and reads notifications on the homelab's ntfy bus, so
// any agent can alert a human and check recent infra alerts the same
// way. See doc/tool-parity.md §3.3.
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/notifyr/notify"
)
var version = "dev"
const usage = `notifyr - ntfy client for agents (send and read notifications)
Usage:
notifyr send --msg "text" [--topic T] [--title X]
[--priority min|low|default|high|urgent] [--tags a,b]
notifyr read [--topic T] [--since 10m|2h|all] [--limit N]
notifyr topics list known topics (from the config)
notifyr version
Config: ~/.config/notifyr/config.json (created on first run; override
path with NOTIFYR_CONFIG). Holds server URL, optional token, the
default topic and the known-topics table.
Examples:
notifyr send --topic ci-fel --title "Build failed" --msg "agent-tools arm64: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # what has alerted lately?
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
cfgPath := notify.ConfigPath()
switch os.Args[1] {
case "send":
cmdSend(cfgPath, os.Args[2:])
case "read":
cmdRead(cfgPath, os.Args[2:])
case "topics":
cmdTopics(cfgPath)
case "version", "--version", "-v":
fmt.Println("notifyr", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'notifyr help'", os.Args[1])
}
}
func cmdSend(cfgPath string, args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
topic := fs.String("topic", "", "topic (default: default_topic from config)")
title := fs.String("title", "", "notification title")
msg := fs.String("msg", "", "message text (required)")
priority := fs.String("priority", "", "min|low|default|high|urgent or 1-5")
tags := fs.String("tags", "", "comma-separated tags/emoji shortcodes")
fs.Parse(args)
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
if *topic == "" {
*topic = cfg.DefaultTopic
}
var tagList []string
if *tags != "" {
tagList = strings.Split(*tags, ",")
}
if err := notify.New(cfg.Server, cfg.Token).Send(*topic, *title, *msg, *priority, tagList); err != nil {
die("%v", err)
}
fmt.Printf("sent to %s/%s\n", cfg.Server, *topic)
}
func cmdRead(cfgPath string, args []string) {
fs := flag.NewFlagSet("read", flag.ExitOnError)
topic := fs.String("topic", "", "topic (default: default_topic from config)")
since := fs.String("since", "12h", "how far back: 10m, 2h, unix timestamp or all")
limit := fs.Int("limit", 0, "print at most N (newest) messages")
fs.Parse(args)
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
if *topic == "" {
*topic = cfg.DefaultTopic
}
msgs, err := notify.New(cfg.Server, cfg.Token).Read(*topic, *since, *limit)
if err != nil {
die("%v", err)
}
if len(msgs) == 0 {
fmt.Printf("no messages on %s since %s\n", *topic, *since)
return
}
for _, m := range msgs {
fmt.Println(notify.Format(m))
}
}
func cmdTopics(cfgPath string) {
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
names := make([]string, 0, len(cfg.Topics))
for n := range cfg.Topics {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
mark := " "
if n == cfg.DefaultTopic {
mark = "* "
}
fmt.Printf("%s%-18s %s\n", mark, n, cfg.Topics[n])
}
fmt.Fprintln(os.Stderr, "(* = default topic)")
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "notifyr: "+format+"\n", args...)
os.Exit(1)
}

78
notifyr/notify/config.go Normal file
View File

@@ -0,0 +1,78 @@
package notify
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Config is ~/.config/notifyr/config.json, created with homelab
// defaults on first run. NOTIFYR_CONFIG overrides the path.
type Config struct {
Server string `json:"server"`
Token string `json:"token"`
DefaultTopic string `json:"default_topic"`
Topics map[string]string `json:"topics"` // known topics -> what they carry (informational)
}
func DefaultConfig() *Config {
return &Config{
Server: "https://ntfy.brasse-pc.eu",
Token: "",
DefaultTopic: "claude",
Topics: map[string]string{
"claude": "agents' direct notes to Björn",
"agent-helm": "agent-helm events (question waiting, session died)",
"Info": "*arr system events",
"media-hamtningar": "media grabbed for download",
"media-nytt": "new media landed in Jellyfin",
"pi5-server": "server maintenance (reboots, watchtower)",
"pi5-server-fel": "server problems: failed units, disk space",
"ci-fel": "failed Gitea Actions builds",
"monitoring": "Uptime Kuma up/down alerts",
},
}
}
func ConfigPath() string {
if p := os.Getenv("NOTIFYR_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "notifyr", "config.json")
}
// LoadConfig reads the config, creating it with defaults on first run.
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := SaveConfig(path, cfg); err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "notifyr: created %s\n", path)
return cfg, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o600)
}

165
notifyr/notify/notify.go Normal file
View File

@@ -0,0 +1,165 @@
// Package notify is a thin client for a ntfy server: publish
// notifications and poll past ones, so agents can both alert humans
// and check what the infrastructure has been complaining about.
package notify
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Message is one ntfy message as returned by the /json poll endpoint.
type Message struct {
ID string `json:"id"`
Time int64 `json:"time"`
Event string `json:"event"`
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
}
// Client talks to one ntfy server.
type Client struct {
Server string // e.g. https://ntfy.brasse-pc.eu
Token string // optional bearer token
HTTP *http.Client
}
func New(server, token string) *Client {
return &Client{
Server: strings.TrimRight(server, "/"),
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) auth(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
}
// ValidPriority reports whether p is a priority ntfy accepts.
func ValidPriority(p string) bool {
switch p {
case "", "1", "2", "3", "4", "5", "min", "low", "default", "high", "max", "urgent":
return true
}
return false
}
// Send publishes a message to a topic.
func (c *Client) Send(topic, title, msg, priority string, tags []string) error {
if topic == "" {
return fmt.Errorf("no topic given (flag --topic or default_topic in the config)")
}
if msg == "" {
return fmt.Errorf("empty message")
}
if !ValidPriority(priority) {
return fmt.Errorf("invalid priority %q (use min|low|default|high|urgent or 1-5)", priority)
}
req, err := http.NewRequest("POST", c.Server+"/"+url.PathEscape(topic), strings.NewReader(msg))
if err != nil {
return err
}
c.auth(req)
if title != "" {
req.Header.Set("Title", title)
}
if priority != "" {
req.Header.Set("Priority", priority)
}
if len(tags) > 0 {
req.Header.Set("Tags", strings.Join(tags, ","))
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
return fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return nil
}
// Read polls past messages from a topic. since accepts ntfy's formats:
// a duration ("10m", "2h"), a unix timestamp, a message id, or "all".
func (c *Client) Read(topic, since string, limit int) ([]Message, error) {
if topic == "" {
return nil, fmt.Errorf("no topic given (flag --topic or default_topic in the config)")
}
if since == "" {
since = "all"
}
u := fmt.Sprintf("%s/%s/json?poll=1&since=%s", c.Server, url.PathEscape(topic), url.QueryEscape(since))
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
c.auth(req)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var out []Message
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var m Message
if err := json.Unmarshal([]byte(line), &m); err != nil {
continue // tolerate junk lines; poll output is one JSON object per line
}
if m.Event != "message" {
continue
}
out = append(out, m)
}
if err := sc.Err(); err != nil {
return nil, err
}
if limit > 0 && len(out) > limit {
out = out[len(out)-limit:] // keep the newest
}
return out, nil
}
// Format renders a message as one stable, greppable line.
func Format(m Message) string {
ts := time.Unix(m.Time, 0).Format("2006-01-02 15:04:05")
prio := ""
switch {
case m.Priority >= 4:
prio = " [high]"
case m.Priority > 0 && m.Priority <= 2:
prio = " [low]"
}
title := ""
if m.Title != "" {
title = " (" + m.Title + ")"
}
tags := ""
if len(m.Tags) > 0 {
tags = " #" + strings.Join(m.Tags, " #")
}
return fmt.Sprintf("%s%s%s %s%s", ts, prio, title, strings.ReplaceAll(m.Message, "\n", " ⏎ "), tags)
}

View File

@@ -0,0 +1,135 @@
package notify
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func TestSendSetsHeadersAndBody(t *testing.T) {
var got *http.Request
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
body = string(b[:n])
fmt.Fprint(w, `{"id":"x"}`)
}))
defer srv.Close()
c := New(srv.URL, "tok123")
err := c.Send("ci-fel", "Build failed", "arm64 test: FAIL", "high", []string{"warning", "ci"})
if err != nil {
t.Fatal(err)
}
if got.URL.Path != "/ci-fel" {
t.Errorf("path = %q", got.URL.Path)
}
if body != "arm64 test: FAIL" {
t.Errorf("body = %q", body)
}
for hdr, want := range map[string]string{
"Title": "Build failed",
"Priority": "high",
"Tags": "warning,ci",
"Authorization": "Bearer tok123",
} {
if v := got.Header.Get(hdr); v != want {
t.Errorf("%s = %q, want %q", hdr, v, want)
}
}
}
func TestSendValidation(t *testing.T) {
c := New("http://example.invalid", "")
if err := c.Send("", "", "hello", "", nil); err == nil {
t.Error("empty topic accepted")
}
if err := c.Send("t", "", "", "", nil); err == nil {
t.Error("empty message accepted")
}
if err := c.Send("t", "", "hello", "banana", nil); err == nil {
t.Error("bogus priority accepted")
}
}
func TestReadParsesPollOutput(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("poll") != "1" {
t.Errorf("poll param missing: %s", r.URL.RawQuery)
}
if r.URL.Query().Get("since") != "2h" {
t.Errorf("since = %q", r.URL.Query().Get("since"))
}
fmt.Fprintln(w, `{"id":"a","time":1754500000,"event":"message","topic":"t","message":"first","priority":3}`)
fmt.Fprintln(w, `{"id":"b","time":1754500060,"event":"keepalive","topic":"t"}`)
fmt.Fprintln(w, `not json at all`)
fmt.Fprintln(w, `{"id":"c","time":1754500120,"event":"message","topic":"t","title":"T","message":"second","priority":5,"tags":["x"]}`)
}))
defer srv.Close()
msgs, err := New(srv.URL, "").Read("t", "2h", 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2 (keepalive + junk filtered)", len(msgs))
}
if msgs[0].Message != "first" || msgs[1].Title != "T" {
t.Errorf("unexpected messages: %+v", msgs)
}
}
func TestReadLimitKeepsNewest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for i := 1; i <= 5; i++ {
fmt.Fprintf(w, "{\"id\":\"%d\",\"time\":%d,\"event\":\"message\",\"topic\":\"t\",\"message\":\"m%d\"}\n", i, 1754500000+i, i)
}
}))
defer srv.Close()
msgs, err := New(srv.URL, "").Read("t", "all", 2)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 || msgs[0].Message != "m4" || msgs[1].Message != "m5" {
t.Errorf("limit should keep the newest: %+v", msgs)
}
}
func TestFormatIsOneGreppableLine(t *testing.T) {
line := Format(Message{Time: 1754500000, Title: "Backup", Message: "done\nall good", Priority: 4, Tags: []string{"ok"}})
if strings.Contains(line, "\n") {
t.Error("format must be a single line")
}
for _, want := range []string{"[high]", "(Backup)", "done", "#ok"} {
if !strings.Contains(line, want) {
t.Errorf("line %q missing %q", line, want)
}
}
}
func TestConfigRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg, err := LoadConfig(path) // first run creates defaults
if err != nil {
t.Fatal(err)
}
if cfg.Server == "" || cfg.DefaultTopic == "" {
t.Error("defaults incomplete")
}
cfg.DefaultTopic = "elsewhere"
if err := SaveConfig(path, cfg); err != nil {
t.Fatal(err)
}
again, err := LoadConfig(path)
if err != nil {
t.Fatal(err)
}
if again.DefaultTopic != "elsewhere" {
t.Error("saved change did not persist")
}
}

68
sfx-maker/README.md Normal file
View File

@@ -0,0 +1,68 @@
# sfx-maker (`sfxc`)
Synthesizes **retro game sound effects** (sfxr-style) from `.sfx` text
files and writes 16-bit mono **WAV**. Sound design becomes text an agent
can read, tweak and reason about — no DAW needed. Go, zero
dependencies, single static binary.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd sfx-maker
go build -o build/sfxc . # or the VS Code task "build sfx-maker"
go test ./...
```
## Quick start
```bash
sfxc preset jump -o jump.sfx # editable text preset
sfxc build jump.sfx # -> jump.wav
mpv jump.wav # listen (or aplay/ffplay)
sfxc preset coin -o coin.wav # straight to WAV
sfxc preset coin --seed 3 -o coin3.wav # deterministic variant
sfxc info laser.sfx # validate + summary
```
Presets: `blip, coin, explosion, hurt, jump, laser, powerup`.
`--seed 0` is the canonical sound; any other seed nudges pitch/length
deterministically — ask for three seeds, keep the one that sounds best.
## The `.sfx` format
```
# comment
sfx: jump name
wave: square square | saw | sine | triangle | noise
volume: 0.7 0..1
attack: 0.01 seconds: fade in
sustain: 0.08 hold at full volume
decay: 0.18 fade out (total length = a+s+d, max 10 s)
freq: 330 start pitch, Hz
freq-slide: 900 Hz/second (positive = rising, negative = falling)
duty: 0.5 square pulse width 0.05..0.95 (thinner = buzzier)
vibrato-depth: 25 pitch wobble, Hz
vibrato-rate: 9 wobbles per second
arpeggio: 1.335 multiply pitch by this...
arpeggio-time: 0.06 ...after this many seconds (classic coin blip)
lowpass: 2200 cutoff Hz (muffle: explosions, thuds)
highpass: 300 cutoff Hz (thin out: lasers, clicks)
sample-rate: 44100 8000-96000
seed: 1 noise randomness — same seed = same sound
```
Unknown keys are **errors**, so typos surface immediately. Everything is
deterministic: same file → byte-identical WAV.
## Recipe intuition for agents
- **Jump**: square wave + rising `freq-slide`.
- **Coin/pickup**: square + `arpeggio` > 1 shortly after the start.
- **Laser**: saw + steep negative `freq-slide` + `highpass`.
- **Explosion**: noise + `lowpass` ~2 kHz + long `decay`.
- **Hurt**: saw, low pitch, quick fall.
- **Power-up**: rising slide + `vibrato`.
[`examples/`](examples/) contains all presets as `.sfx` + rendered `.wav`.

View File

@@ -0,0 +1,9 @@
# blip preset (seed 0) — edit freely, then: sfxc build examples/blip.sfx
sfx: blip
wave: square
volume: 0.7
attack: 0.002
sustain: 0.03
decay: 0.05
freq: 660
duty: 0.4

BIN
sfx-maker/examples/blip.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
# coin preset (seed 0) — edit freely, then: sfxc build examples/coin.sfx
sfx: coin
wave: square
volume: 0.7
attack: 0.005
sustain: 0.08
decay: 0.25
freq: 988
arpeggio: 1.335
arpeggio-time: 0.06

BIN
sfx-maker/examples/coin.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# explosion preset (seed 0) — edit freely, then: sfxc build examples/explosion.sfx
sfx: explosion
wave: noise
volume: 0.7
attack: 0.01
sustain: 0.15
decay: 0.55
freq-slide: -600
lowpass: 2200

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# hurt preset (seed 0) — edit freely, then: sfxc build examples/hurt.sfx
sfx: hurt
wave: saw
volume: 0.7
attack: 0.005
sustain: 0.04
decay: 0.14
freq: 300
freq-slide: -700

BIN
sfx-maker/examples/hurt.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# jump preset (seed 0) — edit freely, then: sfxc build examples/jump.sfx
sfx: jump
wave: square
volume: 0.7
attack: 0.01
sustain: 0.08
decay: 0.18
freq: 330
freq-slide: 900

BIN
sfx-maker/examples/jump.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
# laser preset (seed 0) — edit freely, then: sfxc build examples/laser.sfx
sfx: laser
wave: saw
volume: 0.7
attack: 0.005
sustain: 0.05
decay: 0.12
freq: 1400
freq-slide: -6000
highpass: 300

Binary file not shown.

View File

@@ -0,0 +1,12 @@
# powerup preset (seed 0) — edit freely, then: sfxc build examples/powerup.sfx
sfx: powerup
wave: square
volume: 0.7
attack: 0.01
sustain: 0.25
decay: 0.25
freq: 220
freq-slide: 700
duty: 0.4
vibrato-depth: 25
vibrato-rate: 9

Binary file not shown.

3
sfx-maker/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/sfx-maker
go 1.24

212
sfx-maker/main.go Normal file
View File

@@ -0,0 +1,212 @@
// sfxc synthesizes retro game sound effects from .sfx text files
// (sfxr-style parameters) and writes 16-bit mono WAV.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/sfx-maker/sfx"
)
var version = "dev"
const usage = `sfxc - sound effect maker for agents
Usage:
sfxc build <file.sfx> [-o out.wav] synthesize a .sfx file to WAV
sfxc preset <name> [-o out.sfx|out.wav] [--seed n]
write a preset (editable .sfx text,
or straight to .wav)
sfxc info <file.sfx> validate + print parameters
sfxc version
Presets: blip, coin, explosion, hurt, jump, laser, powerup
--seed 0 (default) is the canonical sound; other seeds give variants.
The .sfx format (all keys optional, '#' comments):
sfx: jump name
wave: square square | saw | sine | triangle | noise
volume: 0.7 0..1
attack: 0.01 seconds: fade in
sustain: 0.08 hold
decay: 0.18 fade out
freq: 330 start pitch, Hz
freq-slide: 900 Hz per second (negative = falling)
duty: 0.5 square pulse width 0.05..0.95
vibrato-depth: 25 Hz
vibrato-rate: 9 Hz
arpeggio: 1.335 pitch multiplier that kicks in at...
arpeggio-time: 0.06 ...this many seconds
lowpass: 2200 filter cutoff Hz
highpass: 300 filter cutoff Hz
sample-rate: 44100
seed: 1 noise randomness (deterministic)
Play the result with e.g.: mpv out.wav or aplay out.wav
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "preset":
cmdPreset(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("sfxc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'sfxc help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
func writeWAVFile(path string, p *sfx.Params) error {
samples := sfx.Render(p)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err := sfx.WriteWAV(f, samples, p.SampleRate); err != nil {
return err
}
return f.Close()
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output .wav (default: input name with .wav)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("build takes exactly one .sfx file")
}
p, err := sfx.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
path := *out
if path == "" {
path = strings.TrimSuffix(fs.Arg(0), filepath.Ext(fs.Arg(0))) + ".wav"
}
if err := writeWAVFile(path, p); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s, %.2fs, %d Hz)\n", path, p.Wave, p.Duration(), p.SampleRate)
}
func cmdPreset(args []string) {
fs := flag.NewFlagSet("preset", flag.ExitOnError)
out := fs.String("o", "", "output file: .sfx (editable text) or .wav (rendered)")
seed := fs.Int64("seed", 0, "0 = canonical, other values = variants")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("preset takes exactly one preset name (%s)", sfx.PresetNames())
}
name := fs.Arg(0)
p, err := sfx.Preset(name, *seed)
if err != nil {
die("%v", err)
}
path := *out
if path == "" {
path = name + ".sfx"
}
switch strings.ToLower(filepath.Ext(path)) {
case ".wav":
if err := writeWAVFile(path, p); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s preset, seed %d, %.2fs)\n", path, name, *seed, p.Duration())
case ".sfx":
f, err := os.Create(path)
if err != nil {
die("%v", err)
}
defer f.Close()
comment := fmt.Sprintf("%s preset (seed %d) — edit freely, then: sfxc build %s", name, *seed, path)
if err := p.Write(f, comment); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s preset, seed %d — edit then 'sfxc build')\n", path, name, *seed)
default:
die("-o must end in .sfx or .wav")
}
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .sfx file")
}
p, err := sfx.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("sfx: %s\n", p.Name)
fmt.Printf("wave: %s\n", p.Wave)
fmt.Printf("duration: %.3fs (attack %.3g + sustain %.3g + decay %.3g)\n",
p.Duration(), p.Attack, p.Sustain, p.Decay)
if p.Wave != "noise" {
fmt.Printf("freq: %g Hz", p.Freq)
if p.FreqSlide != 0 {
fmt.Printf(" (slide %+g Hz/s)", p.FreqSlide)
}
fmt.Println()
}
if p.ArpFactor != 0 {
fmt.Printf("arpeggio: x%g at %gs\n", p.ArpFactor, p.ArpTime)
}
if p.VibratoDepth > 0 {
fmt.Printf("vibrato: ±%g Hz at %g Hz\n", p.VibratoDepth, p.VibratoRate)
}
if p.LowPass > 0 {
fmt.Printf("lowpass: %g Hz\n", p.LowPass)
}
if p.HighPass > 0 {
fmt.Printf("highpass: %g Hz\n", p.HighPass)
}
fmt.Printf("volume: %g\nsamplerate:%d\n", p.Volume, p.SampleRate)
fmt.Println("valid: yes")
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "sfxc: "+format+"\n", a...)
os.Exit(1)
}

223
sfx-maker/sfx/params.go Normal file
View File

@@ -0,0 +1,223 @@
// Package sfx synthesizes retro game sound effects (sfxr-style) from
// text parameter files and renders them to 16-bit mono WAV.
package sfx
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
)
// Params describes one sound effect. Zero values mean "off" for the
// optional effects; Defaults() fills the required fields.
type Params struct {
Name string
Wave string // square | saw | sine | triangle | noise
Volume float64 // 0..1 master gain
// envelope, seconds
Attack float64 // 0 -> Volume
Sustain float64 // hold at Volume
Decay float64 // Volume -> 0
Freq float64 // start frequency, Hz
FreqSlide float64 // Hz per second, may be negative
FreqMin float64 // clamp; sound stops below this (default 20 Hz)
Duty float64 // square wave duty cycle 0.05..0.95 (default 0.5)
VibratoDepth float64 // Hz
VibratoRate float64 // Hz
ArpFactor float64 // frequency multiplier applied at ArpTime (0 = off)
ArpTime float64 // seconds
LowPass float64 // cutoff Hz (0 = off)
HighPass float64 // cutoff Hz (0 = off)
SampleRate int // default 44100
Seed int64 // noise seed (default 1)
}
// Defaults returns a Params with sensible base values.
func Defaults() Params {
return Params{
Wave: "square",
Volume: 0.7,
Attack: 0.01,
Sustain: 0.1,
Decay: 0.15,
Freq: 440,
FreqMin: 20,
Duty: 0.5,
SampleRate: 44100,
Seed: 1,
}
}
// Duration is the total length of the sound in seconds.
func (p *Params) Duration() float64 { return p.Attack + p.Sustain + p.Decay }
// Validate checks ranges and returns a helpful error.
func (p *Params) Validate() error {
switch p.Wave {
case "square", "saw", "sine", "triangle", "noise":
default:
return fmt.Errorf("wave %q must be square, saw, sine, triangle or noise", p.Wave)
}
if p.Volume < 0 || p.Volume > 1 {
return fmt.Errorf("volume %g out of range 0-1", p.Volume)
}
if p.Attack < 0 || p.Sustain < 0 || p.Decay < 0 {
return fmt.Errorf("attack/sustain/decay must be >= 0")
}
if p.Duration() <= 0 {
return fmt.Errorf("total duration is 0 — set attack, sustain and/or decay")
}
if p.Duration() > 10 {
return fmt.Errorf("total duration %.2fs is too long (max 10s)", p.Duration())
}
if p.Wave != "noise" && (p.Freq <= 0 || p.Freq > 20000) {
return fmt.Errorf("freq %g out of range 1-20000 Hz", p.Freq)
}
if p.Duty < 0.05 || p.Duty > 0.95 {
return fmt.Errorf("duty %g out of range 0.05-0.95", p.Duty)
}
if p.SampleRate < 8000 || p.SampleRate > 96000 {
return fmt.Errorf("sample-rate %d out of range 8000-96000", p.SampleRate)
}
return nil
}
// ParseFile reads a .sfx file; the name defaults to the file name.
func ParseFile(path string) (*Params, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
p, err := Parse(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if p.Name == "" {
p.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
return p, nil
}
// Parse reads the .sfx text format: one "key: value" per line,
// '#' comments. Unknown keys are errors so typos surface immediately.
func Parse(r io.Reader) (*Params, error) {
p := Defaults()
sc := bufio.NewScanner(r)
lineNo := 0
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
i := strings.Index(line, ":")
if i < 0 {
return nil, fmt.Errorf("line %d: want 'key: value', got %q", lineNo, line)
}
key := strings.ToLower(strings.TrimSpace(line[:i]))
val := strings.TrimSpace(line[i+1:])
if j := strings.Index(val, " #"); j >= 0 { // trailing comment
val = strings.TrimSpace(val[:j])
}
var err error
switch key {
case "sfx", "name":
p.Name = val
case "wave":
p.Wave = strings.ToLower(val)
case "volume":
p.Volume, err = strconv.ParseFloat(val, 64)
case "attack":
p.Attack, err = strconv.ParseFloat(val, 64)
case "sustain":
p.Sustain, err = strconv.ParseFloat(val, 64)
case "decay":
p.Decay, err = strconv.ParseFloat(val, 64)
case "freq":
p.Freq, err = strconv.ParseFloat(val, 64)
case "freq-slide":
p.FreqSlide, err = strconv.ParseFloat(val, 64)
case "freq-min":
p.FreqMin, err = strconv.ParseFloat(val, 64)
case "duty":
p.Duty, err = strconv.ParseFloat(val, 64)
case "vibrato-depth":
p.VibratoDepth, err = strconv.ParseFloat(val, 64)
case "vibrato-rate":
p.VibratoRate, err = strconv.ParseFloat(val, 64)
case "arpeggio":
p.ArpFactor, err = strconv.ParseFloat(val, 64)
case "arpeggio-time":
p.ArpTime, err = strconv.ParseFloat(val, 64)
case "lowpass":
p.LowPass, err = strconv.ParseFloat(val, 64)
case "highpass":
p.HighPass, err = strconv.ParseFloat(val, 64)
case "sample-rate":
p.SampleRate, err = strconv.Atoi(val)
case "seed":
p.Seed, err = strconv.ParseInt(val, 10, 64)
default:
return nil, fmt.Errorf("line %d: unknown key %q", lineNo, key)
}
if err != nil {
return nil, fmt.Errorf("line %d: %s: bad value %q", lineNo, key, val)
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if err := p.Validate(); err != nil {
return nil, err
}
return &p, nil
}
// Write renders the params back to the .sfx text format (used by the
// preset generator so agents get an editable file).
func (p *Params) Write(w io.Writer, comment string) error {
bw := bufio.NewWriter(w)
if comment != "" {
fmt.Fprintf(bw, "# %s\n", comment)
}
fmt.Fprintf(bw, "sfx: %s\nwave: %s\nvolume: %g\n", p.Name, p.Wave, p.Volume)
fmt.Fprintf(bw, "attack: %g\nsustain: %g\ndecay: %g\n", p.Attack, p.Sustain, p.Decay)
if p.Wave != "noise" {
fmt.Fprintf(bw, "freq: %g\n", p.Freq)
}
if p.FreqSlide != 0 {
fmt.Fprintf(bw, "freq-slide: %g\n", p.FreqSlide)
}
if p.Wave == "square" && p.Duty != 0.5 {
fmt.Fprintf(bw, "duty: %g\n", p.Duty)
}
if p.VibratoDepth > 0 && p.VibratoRate > 0 {
fmt.Fprintf(bw, "vibrato-depth: %g\nvibrato-rate: %g\n", p.VibratoDepth, p.VibratoRate)
}
if p.ArpFactor != 0 {
fmt.Fprintf(bw, "arpeggio: %g\narpeggio-time: %g\n", p.ArpFactor, p.ArpTime)
}
if p.LowPass > 0 {
fmt.Fprintf(bw, "lowpass: %g\n", p.LowPass)
}
if p.HighPass > 0 {
fmt.Fprintf(bw, "highpass: %g\n", p.HighPass)
}
if p.Seed != 1 {
fmt.Fprintf(bw, "seed: %d\n", p.Seed)
}
return bw.Flush()
}

114
sfx-maker/sfx/presets.go Normal file
View File

@@ -0,0 +1,114 @@
package sfx
import (
"fmt"
"math/rand"
"sort"
)
// Preset returns ready-made parameters for classic game sounds.
// seed 0 gives the canonical version; other seeds vary it slightly so
// agents can generate alternatives ("give me three coin variants").
func Preset(name string, seed int64) (*Params, error) {
p := Defaults()
p.Name = name
switch name {
case "jump":
p.Wave = "square"
p.Duty = 0.5
p.Freq = 330
p.FreqSlide = 900
p.Attack = 0.01
p.Sustain = 0.08
p.Decay = 0.18
case "coin":
p.Wave = "square"
p.Duty = 0.5
p.Freq = 988
p.ArpFactor = 1.335 // up a fourth: B5 -> E6
p.ArpTime = 0.06
p.Attack = 0.005
p.Sustain = 0.08
p.Decay = 0.25
case "laser":
p.Wave = "saw"
p.Freq = 1400
p.FreqSlide = -6000
p.Attack = 0.005
p.Sustain = 0.05
p.Decay = 0.12
p.HighPass = 300
case "explosion":
p.Wave = "noise"
p.Freq = 900
p.FreqSlide = -600
p.Attack = 0.01
p.Sustain = 0.15
p.Decay = 0.55
p.LowPass = 2200
case "hurt":
p.Wave = "saw"
p.Freq = 300
p.FreqSlide = -700
p.Attack = 0.005
p.Sustain = 0.04
p.Decay = 0.14
case "powerup":
p.Wave = "square"
p.Duty = 0.4
p.Freq = 220
p.FreqSlide = 700
p.VibratoDepth = 25
p.VibratoRate = 9
p.Attack = 0.01
p.Sustain = 0.25
p.Decay = 0.25
case "blip":
p.Wave = "square"
p.Duty = 0.4
p.Freq = 660
p.Attack = 0.002
p.Sustain = 0.03
p.Decay = 0.05
default:
return nil, fmt.Errorf("unknown preset %q (available: %s)", name, PresetNames())
}
if seed != 0 {
vary(&p, seed)
}
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("preset %s (seed %d): %w", name, seed, err)
}
return &p, nil
}
// vary nudges the tonal parameters deterministically from the seed.
func vary(p *Params, seed int64) {
rng := rand.New(rand.NewSource(seed))
jitter := func(v, amount float64) float64 {
return v * (1 + amount*(rng.Float64()*2-1))
}
p.Freq = jitter(p.Freq, 0.15)
p.FreqSlide = jitter(p.FreqSlide, 0.25)
p.Sustain = jitter(p.Sustain, 0.2)
p.Decay = jitter(p.Decay, 0.2)
if p.ArpFactor != 0 {
p.ArpFactor = jitter(p.ArpFactor, 0.05)
}
p.Seed = seed // noise variation too
}
var presetNames = []string{"blip", "coin", "explosion", "hurt", "jump", "laser", "powerup"}
// PresetNames lists the available presets, sorted.
func PresetNames() string {
sort.Strings(presetNames)
out := ""
for i, n := range presetNames {
if i > 0 {
out += ", "
}
out += n
}
return out
}

164
sfx-maker/sfx/sfx_test.go Normal file
View File

@@ -0,0 +1,164 @@
package sfx
import (
"bytes"
"math"
"strings"
"testing"
)
func TestParseAndValidate(t *testing.T) {
src := `
# a jump
sfx: jump
wave: square
freq: 330
freq-slide: 900
attack: 0.01
sustain: 0.08
decay: 0.18
duty: 0.4
`
p, err := Parse(strings.NewReader(src))
if err != nil {
t.Fatal(err)
}
if p.Name != "jump" || p.Wave != "square" || p.Freq != 330 || p.Duty != 0.4 {
t.Errorf("parsed wrong: %+v", p)
}
if math.Abs(p.Duration()-0.27) > 1e-9 {
t.Errorf("duration = %g, want 0.27", p.Duration())
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"unknown key": "wat: 3\n",
"bad wave": "wave: wobble\n",
"bad value": "freq: abc\n",
"zero length": "attack: 0\nsustain: 0\ndecay: 0\n",
"volume range": "volume: 2\n",
"duty range": "duty: 0.99\n",
}
for name, src := range cases {
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestRenderBasics(t *testing.T) {
p := Defaults()
p.Wave = "sine"
p.Attack, p.Sustain, p.Decay = 0.01, 0.05, 0.05
samples := Render(&p)
want := int(0.11 * 44100)
if len(samples) != want {
t.Errorf("samples = %d, want %d", len(samples), want)
}
var peak float64
for _, s := range samples {
if math.Abs(s) > peak {
peak = math.Abs(s)
}
if s > 1 || s < -1 {
t.Fatalf("sample %g out of range", s)
}
}
if peak < 0.5 {
t.Errorf("peak %g suspiciously quiet", peak)
}
// end of decay should be silent-ish
tail := samples[len(samples)-10:]
for _, s := range tail {
if math.Abs(s) > 0.1 {
t.Errorf("tail sample %g not decayed", s)
}
}
}
func TestRenderDeterministic(t *testing.T) {
p := Defaults()
p.Wave = "noise"
p.Seed = 42
a := Render(&p)
b := Render(&p)
for i := range a {
if a[i] != b[i] {
t.Fatalf("noise render not deterministic at sample %d", i)
}
}
}
func TestAllWavesAndPresets(t *testing.T) {
for _, w := range []string{"square", "saw", "sine", "triangle", "noise"} {
p := Defaults()
p.Wave = w
if s := Render(&p); len(s) == 0 {
t.Errorf("wave %s rendered nothing", w)
}
}
for _, name := range []string{"blip", "coin", "explosion", "hurt", "jump", "laser", "powerup"} {
p, err := Preset(name, 0)
if err != nil {
t.Errorf("preset %s: %v", name, err)
continue
}
s := Render(p)
var sum float64
for _, v := range s {
sum += v * v
}
rms := math.Sqrt(sum / float64(len(s)))
if rms < 0.01 {
t.Errorf("preset %s is nearly silent (rms %g)", name, rms)
}
// variants stay valid
if _, err := Preset(name, 7); err != nil {
t.Errorf("preset %s seed 7: %v", name, err)
}
}
if _, err := Preset("nope", 0); err == nil {
t.Error("unknown preset should error")
}
}
func TestWAVRoundTrip(t *testing.T) {
p := Defaults()
samples := Render(&p)
var buf bytes.Buffer
if err := WriteWAV(&buf, samples, p.SampleRate); err != nil {
t.Fatal(err)
}
sr, bits, ch, dataBytes, err := ReadWAVHeader(&buf)
if err != nil {
t.Fatal(err)
}
if sr != 44100 || bits != 16 || ch != 1 {
t.Errorf("header: sr=%d bits=%d ch=%d", sr, bits, ch)
}
if dataBytes != len(samples)*2 {
t.Errorf("dataBytes = %d, want %d", dataBytes, len(samples)*2)
}
if buf.Len() != dataBytes {
t.Errorf("body length %d != declared %d", buf.Len(), dataBytes)
}
}
func TestParamsWriteRoundTrip(t *testing.T) {
p, err := Preset("coin", 3)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := p.Write(&buf, "test"); err != nil {
t.Fatal(err)
}
back, err := Parse(&buf)
if err != nil {
t.Fatalf("re-parse of written .sfx failed: %v\n%s", err, buf.String())
}
if back.Freq != p.Freq || back.ArpFactor != p.ArpFactor || back.Seed != p.Seed {
t.Errorf("roundtrip mismatch: %+v vs %+v", back, p)
}
}

122
sfx-maker/sfx/synth.go Normal file
View File

@@ -0,0 +1,122 @@
package sfx
import (
"math"
"math/rand"
)
// Render synthesizes the effect into float64 samples in [-1, 1].
func Render(p *Params) []float64 {
sr := float64(p.SampleRate)
n := int(p.Duration() * sr)
out := make([]float64, n)
rng := rand.New(rand.NewSource(p.Seed))
phase := 0.0
noiseVal := 0.0
noiseCounter := 0.0
// one-pole filter states
lpState := 0.0
hpState := 0.0
hpPrevIn := 0.0
dt := 1 / sr
lpAlpha := 0.0
if p.LowPass > 0 {
rc := 1 / (2 * math.Pi * p.LowPass)
lpAlpha = dt / (rc + dt)
}
hpAlpha := 0.0
if p.HighPass > 0 {
rc := 1 / (2 * math.Pi * p.HighPass)
hpAlpha = rc / (rc + dt)
}
for i := 0; i < n; i++ {
t := float64(i) / sr
f := p.Freq + p.FreqSlide*t
if p.ArpFactor != 0 && p.ArpTime > 0 && t >= p.ArpTime {
f *= p.ArpFactor
}
if p.VibratoDepth > 0 && p.VibratoRate > 0 {
f += p.VibratoDepth * math.Sin(2*math.Pi*p.VibratoRate*t)
}
if f < p.FreqMin {
f = p.FreqMin
}
var s float64
if p.Wave == "noise" {
// pitched noise: new random value f*4 times per second
noiseCounter += f * 4 * dt
if noiseCounter >= 1 || i == 0 {
noiseCounter = math.Mod(noiseCounter, 1)
noiseVal = rng.Float64()*2 - 1
}
s = noiseVal
} else {
phase += f * dt
ph := math.Mod(phase, 1)
switch p.Wave {
case "square":
if ph < p.Duty {
s = 1
} else {
s = -1
}
case "saw":
s = 2*ph - 1
case "triangle":
if ph < 0.5 {
s = 4*ph - 1
} else {
s = 3 - 4*ph
}
case "sine":
s = math.Sin(2 * math.Pi * ph)
}
}
s *= envelope(p, t)
if lpAlpha > 0 {
lpState += lpAlpha * (s - lpState)
s = lpState
}
if hpAlpha > 0 {
hpState = hpAlpha * (hpState + s - hpPrevIn)
hpPrevIn = s
s = hpState
}
s *= p.Volume
if s > 1 {
s = 1
} else if s < -1 {
s = -1
}
out[i] = s
}
return out
}
// envelope is a linear attack / sustain / decay gain in 0..1.
func envelope(p *Params, t float64) float64 {
switch {
case t < p.Attack:
return t / p.Attack
case t < p.Attack+p.Sustain:
return 1
default:
d := t - p.Attack - p.Sustain
if p.Decay <= 0 {
return 0
}
g := 1 - d/p.Decay
if g < 0 {
g = 0
}
return g
}
}

54
sfx-maker/sfx/wav.go Normal file
View File

@@ -0,0 +1,54 @@
package sfx
import (
"encoding/binary"
"fmt"
"io"
"math"
)
// WriteWAV encodes samples ([-1,1] floats) as a 16-bit mono PCM WAV.
func WriteWAV(w io.Writer, samples []float64, sampleRate int) error {
dataLen := len(samples) * 2
var hdr [44]byte
copy(hdr[0:4], "RIFF")
binary.LittleEndian.PutUint32(hdr[4:8], uint32(36+dataLen))
copy(hdr[8:12], "WAVE")
copy(hdr[12:16], "fmt ")
binary.LittleEndian.PutUint32(hdr[16:20], 16) // fmt chunk size
binary.LittleEndian.PutUint16(hdr[20:22], 1) // PCM
binary.LittleEndian.PutUint16(hdr[22:24], 1) // mono
binary.LittleEndian.PutUint32(hdr[24:28], uint32(sampleRate)) // sample rate
binary.LittleEndian.PutUint32(hdr[28:32], uint32(sampleRate*2)) // byte rate
binary.LittleEndian.PutUint16(hdr[32:34], 2) // block align
binary.LittleEndian.PutUint16(hdr[34:36], 16) // bits per sample
copy(hdr[36:40], "data")
binary.LittleEndian.PutUint32(hdr[40:44], uint32(dataLen))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
buf := make([]byte, 2*len(samples))
for i, s := range samples {
v := int16(math.Round(s * 32767))
binary.LittleEndian.PutUint16(buf[i*2:], uint16(v))
}
_, err := w.Write(buf)
return err
}
// ReadWAVHeader sanity-parses a WAV header (used in tests and info).
func ReadWAVHeader(r io.Reader) (sampleRate, bits, channels, dataBytes int, err error) {
var hdr [44]byte
if _, err = io.ReadFull(r, hdr[:]); err != nil {
return
}
if string(hdr[0:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" {
err = fmt.Errorf("not a WAV file")
return
}
channels = int(binary.LittleEndian.Uint16(hdr[22:24]))
sampleRate = int(binary.LittleEndian.Uint32(hdr[24:28]))
bits = int(binary.LittleEndian.Uint16(hdr[34:36]))
dataBytes = int(binary.LittleEndian.Uint32(hdr[40:44]))
return
}

82
svg-maker/README.md Normal file
View File

@@ -0,0 +1,82 @@
# svg-maker — `svgc`, vector graphics for agents
Builds SVG images from a line-based text format (`.svgd`) that an
agent can author directly, **verify without a GUI** (terminal preview
+ measurements) and show to a human through agent-helm
(`helmd share out.svg`). Built for "graphical elements": status cards,
diagrams, icons, simple illustrations.
## Usage
```
svgc build <file.svgd> [-o out.svg] [--preview] [--width N]
svgc preview <file.svgd> [--width N] truecolor half-block render
svgc info <file.svgd> counts, colors, bbox, warnings
svgc example annotated example to start from
```
Typical agent flow:
```bash
svgc example > card.svgd # start from the example
# ...edit card.svgd...
svgc build card.svgd --preview # writes card.svg + shows what it looks like
helmd share card.svg --note "status card" # display it in agent-helm
```
`build` prints an info block after writing — element counts, colors,
the drawing's bounding box and **warnings for anything outside the
canvas** — so mistakes surface as text even without the preview.
Errors carry line numbers (`card.svgd: line 7: "four" is not a
number — rect needs: rect <x> <y> <w> <h>`).
## The .svgd format
One element per line, `#` comments, `key=value` attributes last:
```
canvas 240 120 # required: width height
bg #12161f # optional background
def accent #4f9cf9 # named color, use as $accent
rect 10 10 60 40 fill=$accent rx=6
circle 120 40 20 fill=#3fca7c stroke=white stroke-width=2
ellipse 60 90 30 12 fill=gray
line 10 100 190 100 stroke=red width=3
polyline 10,20 30,40 50,10 stroke=white
polygon 20,80 40,60 60,80 fill=#e0a63f
path M10,10 L50,50 Q70,20 90,50 Z stroke=white
text 100 60 "Hello agent-helm" size=14 fill=white anchor=middle bold
group stroke=gray stroke-width=1 # group attrs are inherited
line 0 0 10 10
end
```
| Piece | Notes |
|---|---|
| `canvas w h` | required first; becomes the viewBox and image size |
| `bg color` | background rect |
| `def name color` | color variable; `$name` in any fill/stroke |
| attributes | `fill stroke stroke-width` (alias `width`) `opacity fill-opacity stroke-opacity rx ry anchor size font dash linecap linejoin transform id` + flags `bold italic` |
| colors | `#rgb`, `#rrggbb`, CSS names, `none`; unknown attribute keys are errors |
| `path` | subset `M L H V C Q Z`, absolute + relative, commas or spaces |
| `transform` | raw SVG transform with commas: `transform=rotate(45,50,50)` |
Friendly defaults: `line`/`polyline`/`path` get a visible stroke if
you give none, `polyline` gets `fill=none` — no invisible elements,
no accidental filled blobs.
## Preview fidelity
The preview rasterizes the same shape model the emitter writes:
fills, strokes, opacity and group inheritance are honored. Two
approximations: `rx` rounded corners render square, and **text renders
as its baseline box** (real glyphs are a font problem — check text in
the real render via agent-helm). `info` always reflects true geometry.
## Build & test
```bash
go test ./...
go build -o build/svgc .
```

View File

@@ -0,0 +1,12 @@
# gauge.svgd - example: a small status card
canvas 240 120
bg #12161f
def ok #3fca7c
def frame #262d3a
rect 8 8 224 104 fill=none stroke=$frame stroke-width=2 rx=10
circle 40 60 22 fill=none stroke=$ok stroke-width=6
path M30,60 L38,68 L52,50 stroke=$ok width=5 linecap=round fill=none
text 76 54 "backups" size=13 fill=#8a93a5
text 76 76 "all green" size=17 fill=white bold

3
svg-maker/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/svg-maker
go 1.24

176
svg-maker/main.go Normal file
View File

@@ -0,0 +1,176 @@
// svgc builds SVG images from .svgd text descriptions — vector
// graphics an agent can author, verify (terminal preview + info) and
// share to agent-helm with `helmd share out.svg`.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/svg-maker/svg"
)
var version = "dev"
const usage = `svgc - SVG maker for agents
Usage:
svgc build <file.svgd> [-o out.svg] [--preview] [--width N]
svgc preview <file.svgd> [--width N] draw it in the terminal
svgc info <file.svgd> counts, colors, bbox, warnings
svgc example print an annotated example file
svgc version
The .svgd format ('#' comments, one element per line):
canvas 200 120 required: width height
bg #1a2029 optional background
def accent #4f9cf9 named color, use as $accent
rect 10 10 60 40 fill=$accent rx=6
circle 120 40 20 fill=#3fca7c stroke=white stroke-width=2
ellipse 60 90 30 12 fill=gray
line 10 100 190 100 stroke=red width=3
polyline 10,20 30,40 50,10 stroke=white
polygon 20,80 40,60 60,80 fill=#e0a63f
path M10,10 L50,50 Q70,20 90,50 Z stroke=white
text 100 60 "Hello agent-helm" size=14 fill=white anchor=middle bold
group stroke=gray stroke-width=1 group attrs are inherited
line 0 0 10 10
end
Attributes: fill stroke stroke-width|width opacity fill-opacity
stroke-opacity rx ry anchor size font dash linecap linejoin transform
id, plus flags bold italic. Colors: #rgb #rrggbb, CSS names, none.
Show the result in agent-helm: helmd share out.svg --note "diagram"
`
const exampleFile = `# gauge.svgd - example: a small status card
canvas 240 120
bg #12161f
def ok #3fca7c
def frame #262d3a
rect 8 8 224 104 fill=none stroke=$frame stroke-width=2 rx=10
circle 40 60 22 fill=none stroke=$ok stroke-width=6
path M30,60 L38,68 L52,50 stroke=$ok width=5 linecap=round fill=none
text 76 54 "backups" size=13 fill=#8a93a5
text 76 76 "all green" size=17 fill=white bold
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "example":
fmt.Print(exampleFile)
case "version", "--version", "-v":
fmt.Println("svgc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'svgc help'", os.Args[1])
}
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output file (default: input with .svg)")
preview := fs.Bool("preview", false, "also draw the result in the terminal")
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("build takes exactly one .svgd file")
}
xml, doc := load(pos[0])
dst := *out
if dst == "" {
dst = strings.TrimSuffix(pos[0], filepath.Ext(pos[0])) + ".svg"
}
if err := os.WriteFile(dst, []byte(xml), 0o644); err != nil {
die("%v", err)
}
fmt.Printf("wrote %s (%d bytes)\n", dst, len(xml))
fmt.Print(svg.Info(doc))
if *preview {
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
}
func cmdPreview(args []string) {
fs := flag.NewFlagSet("preview", flag.ExitOnError)
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("preview takes exactly one .svgd file")
}
_, doc := load(pos[0])
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .svgd file")
}
_, doc := load(args[0])
fmt.Print(svg.Info(doc))
}
// load reads and builds a .svgd file (attrs normalized), dying with
// the parser's line-numbered error on failure. Returns the SVG XML
// and the parsed document.
func load(path string) (string, *svg.Doc) {
data, err := os.ReadFile(path)
if err != nil {
die("%v", err)
}
xml, doc, err := svg.Build(string(data))
if err != nil {
die("%s: %v", path, err)
}
return xml, doc
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "svgc: "+format+"\n", args...)
os.Exit(1)
}

135
svg-maker/svg/emit.go Normal file
View File

@@ -0,0 +1,135 @@
package svg
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Emit renders the document as an SVG file.
func Emit(d *Doc) string {
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %s %s" width="%s" height="%s">`,
num(d.W), num(d.H), num(d.W), num(d.H))
b.WriteString("\n")
if d.Bg != "" {
fmt.Fprintf(&b, ` <rect width="100%%" height="100%%" fill="%s"/>`+"\n", d.Bg)
}
for _, el := range d.Elems {
emitElem(&b, el, 1)
}
b.WriteString("</svg>\n")
return b.String()
}
func emitElem(b *strings.Builder, el *Elem, depth int) {
ind := strings.Repeat(" ", depth)
attrs := attrString(el.Attrs)
switch el.Kind {
case "rect":
fmt.Fprintf(b, `%s<rect x="%s" y="%s" width="%s" height="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "circle":
fmt.Fprintf(b, `%s<circle cx="%s" cy="%s" r="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), attrs)
case "ellipse":
fmt.Fprintf(b, `%s<ellipse cx="%s" cy="%s" rx="%s" ry="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "line":
fmt.Fprintf(b, `%s<line x1="%s" y1="%s" x2="%s" y2="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "polyline", "polygon":
pts := make([]string, len(el.Points))
for i, p := range el.Points {
pts[i] = num(p[0]) + "," + num(p[1])
}
fmt.Fprintf(b, `%s<%s points="%s"%s/>`+"\n", ind, el.Kind, strings.Join(pts, " "), attrs)
case "path":
fmt.Fprintf(b, `%s<path d="%s"%s/>`+"\n", ind, escape(el.D), attrs)
case "text":
fmt.Fprintf(b, `%s<text x="%s" y="%s"%s>%s</text>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), attrs, escape(el.Text))
case "group":
fmt.Fprintf(b, "%s<g%s>\n", ind, attrs)
for _, kid := range el.Kids {
emitElem(b, kid, depth+1)
}
fmt.Fprintf(b, "%s</g>\n", ind)
}
}
// defaults SVG would otherwise pick that surprise agents: lines and
// paths get a visible stroke if none set; polyline defaults fill=none
// so it doesn't render as a filled blob.
func effectiveAttrs(el *Elem) map[string]string {
out := map[string]string{}
for k, v := range el.Attrs {
out[k] = v
}
switch el.Kind {
case "line", "polyline":
if out["stroke"] == "" {
out["stroke"] = "black"
}
if el.Kind == "polyline" && out["fill"] == "" {
out["fill"] = "none"
}
case "path":
if out["stroke"] == "" && out["fill"] == "" {
out["stroke"] = "black"
out["fill"] = "none"
}
}
return out
}
func attrString(attrs map[string]string) string {
if len(attrs) == 0 {
return ""
}
keys := make([]string, 0, len(attrs))
for k := range attrs {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
fmt.Fprintf(&b, ` %s="%s"`, k, escape(attrs[k]))
}
return b.String()
}
func num(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func escape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// normalize applies the effective attrs (visibility defaults) onto the
// tree before emit/raster so both outputs agree.
func (d *Doc) normalize() {
var walk func(els []*Elem)
walk = func(els []*Elem) {
for _, el := range els {
el.Attrs = effectiveAttrs(el)
if el.Kind == "group" {
walk(el.Kids)
}
}
}
walk(d.Elems)
}
// Build parses src and emits SVG in one step.
func Build(src string) (string, *Doc, error) {
doc, err := Parse(src)
if err != nil {
return "", nil, err
}
doc.normalize()
return Emit(doc), doc, nil
}

143
svg-maker/svg/info.go Normal file
View File

@@ -0,0 +1,143 @@
package svg
import (
"fmt"
"math"
"sort"
"strings"
)
// Info summarizes a parsed document: element counts, colors, the
// drawing's bounding box and warnings (things drawn outside the
// canvas), so an agent can verify a build without looking at pixels.
func Info(d *Doc) string {
counts := map[string]int{}
colors := map[string]bool{}
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
var warnings []string
var walk func(els []*Elem)
walk = func(els []*Elem) {
for _, el := range els {
if el.Kind == "group" {
counts["group"]++
walk(el.Kids)
continue
}
counts[el.Kind]++
for _, key := range []string{"fill", "stroke"} {
if c := el.Attrs[key]; c != "" && c != "none" && c != "transparent" {
colors[c] = true
}
}
x0, y0, x1, y1, ok := bbox(el)
if !ok {
continue
}
minX = math.Min(minX, x0)
minY = math.Min(minY, y0)
maxX = math.Max(maxX, x1)
maxY = math.Max(maxY, y1)
if x1 < 0 || y1 < 0 || x0 > d.W || y0 > d.H {
warnings = append(warnings, fmt.Sprintf("line %d: %s is entirely outside the canvas", el.Line, el.Kind))
} else if x0 < 0 || y0 < 0 || x1 > d.W || y1 > d.H {
warnings = append(warnings, fmt.Sprintf("line %d: %s sticks outside the canvas", el.Line, el.Kind))
}
}
}
walk(d.Elems)
var b strings.Builder
fmt.Fprintf(&b, "canvas: %gx%g", d.W, d.H)
if d.Bg != "" {
fmt.Fprintf(&b, " bg: %s", d.Bg)
}
b.WriteString("\n")
kinds := make([]string, 0, len(counts))
for k := range counts {
kinds = append(kinds, k)
}
sort.Strings(kinds)
total := 0
parts := make([]string, 0, len(kinds))
for _, k := range kinds {
parts = append(parts, fmt.Sprintf("%s:%d", k, counts[k]))
if k != "group" {
total += counts[k]
}
}
fmt.Fprintf(&b, "elements: %d (%s)\n", total, strings.Join(parts, " "))
if len(colors) > 0 {
cl := make([]string, 0, len(colors))
for c := range colors {
cl = append(cl, c)
}
sort.Strings(cl)
fmt.Fprintf(&b, "colors: %s\n", strings.Join(cl, " "))
}
if total > 0 && !math.IsInf(minX, 1) {
fmt.Fprintf(&b, "drawing bbox: %.4g,%.4g .. %.4g,%.4g\n", minX, minY, maxX, maxY)
}
for _, w := range warnings {
fmt.Fprintf(&b, "warning: %s\n", w)
}
return b.String()
}
// bbox computes an element's geometric bounding box (stroke width and
// transforms not included — good enough for out-of-canvas warnings).
func bbox(el *Elem) (x0, y0, x1, y1 float64, ok bool) {
switch el.Kind {
case "rect":
return el.Nums[0], el.Nums[1], el.Nums[0] + el.Nums[2], el.Nums[1] + el.Nums[3], true
case "circle":
cx, cy, r := el.Nums[0], el.Nums[1], el.Nums[2]
return cx - r, cy - r, cx + r, cy + r, true
case "ellipse":
cx, cy, rx, ry := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
return cx - rx, cy - ry, cx + rx, cy + ry, true
case "line":
return math.Min(el.Nums[0], el.Nums[2]), math.Min(el.Nums[1], el.Nums[3]),
math.Max(el.Nums[0], el.Nums[2]), math.Max(el.Nums[1], el.Nums[3]), true
case "polyline", "polygon":
return pointsBBox(el.Points)
case "path":
subs, err := flattenPath(el.D)
if err != nil {
return 0, 0, 0, 0, false
}
var all [][2]float64
for _, sp := range subs {
all = append(all, sp...)
}
return pointsBBox(all)
case "text":
size := attrFloat(el.Attrs, "font-size", 16)
w := 0.6 * size * float64(len([]rune(el.Text)))
x, y := el.Nums[0], el.Nums[1]
switch el.Attrs["text-anchor"] {
case "middle":
x -= w / 2
case "end":
x -= w
}
return x, y - size, x + w, y, true
}
return 0, 0, 0, 0, false
}
func pointsBBox(pts [][2]float64) (x0, y0, x1, y1 float64, ok bool) {
if len(pts) == 0 {
return 0, 0, 0, 0, false
}
x0, y0 = pts[0][0], pts[0][1]
x1, y1 = x0, y0
for _, p := range pts {
x0 = math.Min(x0, p[0])
y0 = math.Min(y0, p[1])
x1 = math.Max(x1, p[0])
y1 = math.Max(y1, p[1])
}
return x0, y0, x1, y1, true
}

430
svg-maker/svg/parse.go Normal file
View File

@@ -0,0 +1,430 @@
// Package svg turns .svgd text descriptions into SVG images an agent
// can verify: build (emit XML), preview (terminal raster) and info
// (measurements + warnings).
package svg
import (
"fmt"
"strconv"
"strings"
)
// Elem is one drawing element. Kind decides which fields matter:
//
// rect Nums: x y w h
// circle Nums: cx cy r
// ellipse Nums: cx cy rx ry
// line Nums: x1 y1 x2 y2
// polyline Points
// polygon Points
// path D (subset: M L H V C Q Z, absolute + relative)
// text Nums: x y, Text
// group Kids (inherits its Attrs to children)
type Elem struct {
Kind string
Nums []float64
Points [][2]float64
D string
Text string
Attrs map[string]string
Line int
Kids []*Elem
}
// Doc is a parsed .svgd file.
type Doc struct {
W, H float64
Bg string
Elems []*Elem
}
// attrKeys maps .svgd attribute names to SVG presentation attributes.
// Friendly aliases keep the format short; unknown keys are errors so
// typos surface at build time instead of becoming invisible SVG.
var attrKeys = map[string]string{
"fill": "fill",
"stroke": "stroke",
"stroke-width": "stroke-width",
"width": "stroke-width", // common shorthand on line/path
"opacity": "opacity",
"fill-opacity": "fill-opacity",
"stroke-opacity": "stroke-opacity",
"rx": "rx",
"ry": "ry",
"anchor": "text-anchor",
"size": "font-size",
"font": "font-family",
"dash": "stroke-dasharray",
"linecap": "stroke-linecap",
"linejoin": "stroke-linejoin",
"transform": "transform",
"id": "id",
}
// flag attributes expand to fixed key/values.
var flagAttrs = map[string][2]string{
"bold": {"font-weight", "bold"},
"italic": {"font-style", "italic"},
}
type parser struct {
defs map[string]string
line int
}
// Parse reads a .svgd document.
func Parse(src string) (*Doc, error) {
p := &parser{defs: map[string]string{}}
doc := &Doc{W: 100, H: 100}
sawCanvas := false
stack := []*[]*Elem{&doc.Elems} // group nesting; top = current container
groupLines := []int{}
for i, raw := range strings.Split(src, "\n") {
p.line = i + 1
line := strings.TrimSpace(raw)
if idx := findComment(line); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
if line == "" {
continue
}
tokens, err := tokenize(line)
if err != nil {
return nil, p.errf("%v", err)
}
kind, rest := tokens[0], tokens[1:]
switch kind {
case "canvas":
nums, _, err := p.numsAndAttrs(rest, 2, "canvas needs: canvas <width> <height>")
if err != nil {
return nil, err
}
if nums[0] <= 0 || nums[1] <= 0 {
return nil, p.errf("canvas size must be positive")
}
doc.W, doc.H = nums[0], nums[1]
sawCanvas = true
case "bg":
if len(rest) != 1 {
return nil, p.errf("bg needs: bg <color>")
}
c, err := p.color(rest[0])
if err != nil {
return nil, err
}
doc.Bg = c
case "def":
if len(rest) != 2 {
return nil, p.errf("def needs: def <name> <color>")
}
c, err := p.color(rest[1])
if err != nil {
return nil, err
}
p.defs[rest[0]] = c
case "group":
attrs, err := p.attrs(rest)
if err != nil {
return nil, err
}
g := &Elem{Kind: "group", Attrs: attrs, Line: p.line}
*stack[len(stack)-1] = append(*stack[len(stack)-1], g)
stack = append(stack, &g.Kids)
groupLines = append(groupLines, p.line)
case "end":
if len(stack) == 1 {
return nil, p.errf("end without group")
}
stack = stack[:len(stack)-1]
groupLines = groupLines[:len(groupLines)-1]
default:
el, err := p.element(kind, rest)
if err != nil {
return nil, err
}
*stack[len(stack)-1] = append(*stack[len(stack)-1], el)
}
}
if len(stack) > 1 {
return nil, fmt.Errorf("line %d: group is never closed (missing end)", groupLines[len(groupLines)-1])
}
if !sawCanvas {
return nil, fmt.Errorf("missing canvas line (canvas <width> <height>)")
}
return doc, nil
}
func (p *parser) element(kind string, rest []string) (*Elem, error) {
el := &Elem{Kind: kind, Line: p.line}
var err error
switch kind {
case "rect":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "rect needs: rect <x> <y> <w> <h>")
case "circle":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 3, "circle needs: circle <cx> <cy> <r>")
case "ellipse":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "ellipse needs: ellipse <cx> <cy> <rx> <ry>")
case "line":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "line needs: line <x1> <y1> <x2> <y2>")
case "polyline", "polygon":
el.Points, el.Attrs, err = p.pointsAndAttrs(rest, kind)
case "path":
var attrs map[string]string
var dParts []string
split := len(rest)
for i, t := range rest {
if strings.Contains(t, "=") {
split = i
break
}
dParts = append(dParts, t)
}
attrs, err = p.attrs(rest[split:])
el.D, el.Attrs = strings.Join(dParts, " "), attrs
if err == nil && el.D == "" {
err = p.errf("path needs path data (e.g. path M0,0 L10,10 Z)")
}
if err == nil {
if _, ferr := flattenPath(el.D); ferr != nil {
err = p.errf("bad path data: %v", ferr)
}
}
case "text":
if len(rest) < 3 {
return nil, p.errf(`text needs: text <x> <y> "string" [attrs]`)
}
var nums []float64
nums, err = p.nums(rest[:2], 2, "text needs numeric x y")
if err != nil {
return nil, err
}
el.Nums = nums
el.Text = rest[2]
el.Attrs, err = p.attrs(rest[3:])
default:
return nil, p.errf("unknown element %q (rect, circle, ellipse, line, polyline, polygon, path, text, group, def, bg, canvas)", kind)
}
if err != nil {
return nil, err
}
return el, nil
}
func (p *parser) numsAndAttrs(tokens []string, n int, hint string) ([]float64, map[string]string, error) {
if len(tokens) < n {
return nil, nil, p.errf("%s", hint)
}
nums, err := p.nums(tokens[:n], n, hint)
if err != nil {
return nil, nil, err
}
attrs, err := p.attrs(tokens[n:])
return nums, attrs, err
}
func (p *parser) nums(tokens []string, n int, hint string) ([]float64, error) {
if len(tokens) != n {
return nil, p.errf("%s", hint)
}
out := make([]float64, n)
for i, t := range tokens {
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return nil, p.errf("%q is not a number — %s", t, hint)
}
out[i] = v
}
return out, nil
}
func (p *parser) pointsAndAttrs(tokens []string, kind string) ([][2]float64, map[string]string, error) {
var pts [][2]float64
i := 0
for ; i < len(tokens); i++ {
t := tokens[i]
if strings.Contains(t, "=") {
break
}
xy := strings.Split(t, ",")
if len(xy) != 2 {
return nil, nil, p.errf("%s point %q must be x,y", kind, t)
}
x, err1 := strconv.ParseFloat(xy[0], 64)
y, err2 := strconv.ParseFloat(xy[1], 64)
if err1 != nil || err2 != nil {
return nil, nil, p.errf("%s point %q must be numeric x,y", kind, t)
}
pts = append(pts, [2]float64{x, y})
}
min := 2
if kind == "polygon" {
min = 3
}
if len(pts) < min {
return nil, nil, p.errf("%s needs at least %d x,y points", kind, min)
}
attrs, err := p.attrs(tokens[i:])
return pts, attrs, err
}
func (p *parser) attrs(tokens []string) (map[string]string, error) {
out := map[string]string{}
for _, t := range tokens {
if kv, ok := flagAttrs[t]; ok {
out[kv[0]] = kv[1]
continue
}
eq := strings.Index(t, "=")
if eq <= 0 {
return nil, p.errf("expected attribute key=value, got %q", t)
}
key, val := t[:eq], t[eq+1:]
svgKey, ok := attrKeys[key]
if !ok {
return nil, p.errf("unknown attribute %q", key)
}
if val == "" {
return nil, p.errf("attribute %s has empty value", key)
}
if key == "fill" || key == "stroke" {
c, err := p.color(val)
if err != nil {
return nil, err
}
val = c
}
if key == "transform" {
// commas keep the value one token: rotate(45,50,50)
val = strings.ReplaceAll(val, ",", " ")
}
out[svgKey] = val
}
return out, nil
}
// color resolves $vars and validates the value.
func (p *parser) color(v string) (string, error) {
if strings.HasPrefix(v, "$") {
c, ok := p.defs[v[1:]]
if !ok {
return "", p.errf("undefined color variable %s (define it first: def %s #rrggbb)", v, v[1:])
}
return c, nil
}
if v == "none" || v == "transparent" {
return v, nil
}
if strings.HasPrefix(v, "#") {
hexPart := v[1:]
if len(hexPart) != 3 && len(hexPart) != 6 {
return "", p.errf("color %q must be #rgb or #rrggbb", v)
}
for _, r := range hexPart {
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
return "", p.errf("color %q has non-hex digits", v)
}
}
return v, nil
}
for _, r := range v {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
return "", p.errf("color %q must be #hex, a CSS color name, none or $var", v)
}
}
return v, nil // CSS named color — trust the renderer
}
func (p *parser) errf(format string, args ...interface{}) error {
return fmt.Errorf("line %d: %s", p.line, fmt.Sprintf(format, args...))
}
// tokenize splits a line on whitespace, keeping "quoted strings" as
// single tokens (quotes stripped).
func tokenize(line string) ([]string, error) {
var out []string
var cur strings.Builder
inQuote := false
for _, r := range line {
switch {
case r == '"':
if inQuote {
out = append(out, cur.String())
cur.Reset()
inQuote = false
} else {
inQuote = true
}
case !inQuote && (r == ' ' || r == '\t'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if inQuote {
return nil, fmt.Errorf("unterminated quote")
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
if len(out) == 0 {
return nil, fmt.Errorf("empty line")
}
return out, nil
}
// findComment returns the index of a # starting a comment (not inside
// quotes, and not part of a color like #fff).
func findComment(line string) int {
inQuote := false
for i, r := range line {
if r == '"' {
inQuote = !inQuote
}
if r == '#' && !inQuote {
// a color literal follows =, whitespace-then-hex is a comment
if i == 0 {
return 0
}
prev := line[i-1]
if prev == ' ' || prev == '\t' {
// "... # comment" vs "def accent #fff": colors only appear
// after def/bg or key=; a bare hex after a def/bg keyword is
// data, so only treat as comment if it is not valid hex-ish
rest := line[i+1:]
stop := strings.IndexAny(rest, " \t")
word := rest
if stop >= 0 {
word = rest[:stop]
}
if isHexWord(word) {
continue
}
return i
}
}
}
return -1
}
func isHexWord(w string) bool {
if len(w) != 3 && len(w) != 6 {
return false
}
for _, r := range w {
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
return false
}
}
return true
}

195
svg-maker/svg/path.go Normal file
View File

@@ -0,0 +1,195 @@
package svg
import (
"fmt"
"strconv"
"strings"
)
// flattenPath turns a path-data subset (M L H V C Q Z, absolute and
// relative) into one or more polylines, used for validation, preview
// rasterization and measurements. Curves become 16 line segments.
func flattenPath(d string) ([][][2]float64, error) {
tokens, err := pathTokens(d)
if err != nil {
return nil, err
}
var subpaths [][][2]float64
var cur [][2]float64
var x, y, startX, startY float64
i := 0
cmd := ""
need := func(n int) ([]float64, error) {
if i+n > len(tokens) {
return nil, fmt.Errorf("command %s needs %d numbers", cmd, n)
}
out := make([]float64, n)
for j := 0; j < n; j++ {
v, err := strconv.ParseFloat(tokens[i+j], 64)
if err != nil {
return nil, fmt.Errorf("command %s: %q is not a number", cmd, tokens[i+j])
}
out[j] = v
}
i += n
return out, nil
}
flush := func() {
if len(cur) > 1 {
subpaths = append(subpaths, cur)
}
cur = nil
}
for i < len(tokens) {
t := tokens[i]
if len(t) == 1 && strings.ContainsAny(t, "MLHVCQZmlhvcqz") {
cmd = t
i++
if cmd == "Z" || cmd == "z" {
if len(cur) > 0 {
cur = append(cur, [2]float64{startX, startY})
x, y = startX, startY
}
flush()
continue
}
} else if cmd == "" {
return nil, fmt.Errorf("path must start with M/m, got %q", t)
}
// repeated coordinate groups reuse the current command
rel := cmd >= "a" // lowercase = relative
switch strings.ToUpper(cmd) {
case "M":
n, err := need(2)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
}
flush()
x, y = n[0], n[1]
startX, startY = x, y
cur = [][2]float64{{x, y}}
cmd = map[bool]string{true: "l", false: "L"}[rel] // subsequent pairs are implicit lineto
case "L":
n, err := need(2)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
}
x, y = n[0], n[1]
cur = append(cur, [2]float64{x, y})
case "H":
n, err := need(1)
if err != nil {
return nil, err
}
if rel {
n[0] += x
}
x = n[0]
cur = append(cur, [2]float64{x, y})
case "V":
n, err := need(1)
if err != nil {
return nil, err
}
if rel {
n[0] += y
}
y = n[0]
cur = append(cur, [2]float64{x, y})
case "Q":
n, err := need(4)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
n[2] += x
n[3] += y
}
for s := 1; s <= 16; s++ {
t := float64(s) / 16
u := 1 - t
px := u*u*x + 2*u*t*n[0] + t*t*n[2]
py := u*u*y + 2*u*t*n[1] + t*t*n[3]
cur = append(cur, [2]float64{px, py})
}
x, y = n[2], n[3]
case "C":
n, err := need(6)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
n[2] += x
n[3] += y
n[4] += x
n[5] += y
}
for s := 1; s <= 16; s++ {
t := float64(s) / 16
u := 1 - t
px := u*u*u*x + 3*u*u*t*n[0] + 3*u*t*t*n[2] + t*t*t*n[4]
py := u*u*u*y + 3*u*u*t*n[1] + 3*u*t*t*n[3] + t*t*t*n[5]
cur = append(cur, [2]float64{px, py})
}
x, y = n[4], n[5]
default:
return nil, fmt.Errorf("unsupported path command %q (supported: M L H V C Q Z)", cmd)
}
}
flush()
if len(subpaths) == 0 {
return nil, fmt.Errorf("path draws nothing")
}
return subpaths, nil
}
// pathTokens splits path data into command letters and numbers.
func pathTokens(d string) ([]string, error) {
var out []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
for _, r := range d {
switch {
case r == ' ' || r == '\t' || r == ',':
flush()
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
flush()
out = append(out, string(r))
case (r >= '0' && r <= '9') || r == '.' || r == 'e' || r == 'E':
cur.WriteRune(r)
case r == '-' || r == '+':
// sign starts a new number unless it follows an exponent
s := cur.String()
if cur.Len() > 0 && !strings.HasSuffix(s, "e") && !strings.HasSuffix(s, "E") {
flush()
}
cur.WriteRune(r)
default:
return nil, fmt.Errorf("unexpected character %q in path data", r)
}
}
flush()
if len(out) == 0 {
return nil, fmt.Errorf("empty path data")
}
return out, nil
}

332
svg-maker/svg/raster.go Normal file
View File

@@ -0,0 +1,332 @@
package svg
import (
"fmt"
"math"
"strconv"
"strings"
)
// Raster renders the document to a small RGBA grid so an agent can see
// roughly what it drew without an image viewer. Painter's algorithm in
// document order, 2x2 supersampling, honors fill/stroke/opacity.
// Text is approximated by its baseline and an underline-box (real
// glyph rendering is out of scope for a preview).
type Raster struct {
W, H int
Pix [][4]float64 // r g b a, premultiplied-ish blend target
}
type rgba struct{ r, g, b, a float64 }
// RenderGrid rasterizes doc to cols pixels wide (rows follow aspect).
func RenderGrid(d *Doc, cols int) *Raster {
if cols < 8 {
cols = 8
}
if cols > 400 {
cols = 400
}
rows := int(math.Round(float64(cols) * d.H / d.W))
if rows < 1 {
rows = 1
}
if rows > 400 {
rows = 400
}
r := &Raster{W: cols, H: rows, Pix: make([][4]float64, cols*rows)}
bg := parseColor(d.Bg)
if d.Bg == "" {
bg = rgba{0, 0, 0, 0}
}
for i := range r.Pix {
r.Pix[i] = [4]float64{bg.r, bg.g, bg.b, bg.a}
}
scaleX := d.W / float64(cols)
scaleY := d.H / float64(rows)
var walk func(els []*Elem, inherited map[string]string)
walk = func(els []*Elem, inherited map[string]string) {
for _, el := range els {
attrs := merged(inherited, el.Attrs)
if el.Kind == "group" {
walk(el.Kids, attrs)
continue
}
r.drawElem(el, attrs, scaleX, scaleY)
}
}
walk(d.Elems, nil)
return r
}
func merged(parent, child map[string]string) map[string]string {
if parent == nil {
return child
}
out := map[string]string{}
for k, v := range parent {
out[k] = v
}
for k, v := range child {
out[k] = v
}
return out
}
func (r *Raster) drawElem(el *Elem, attrs map[string]string, sx, sy float64) {
fill, hasFill := paint(attrs, "fill", el.Kind)
stroke, hasStroke := paint(attrs, "stroke", el.Kind)
sw := attrFloat(attrs, "stroke-width", 1)
opacity := attrFloat(attrs, "opacity", 1)
inFill, inStroke := coverageFuncs(el, sw, attrs)
if inFill == nil && inStroke == nil {
return
}
for py := 0; py < r.H; py++ {
for px := 0; px < r.W; px++ {
var fillCov, strokeCov float64
for _, dx := range []float64{0.25, 0.75} {
for _, dy := range []float64{0.25, 0.75} {
ux := (float64(px) + dx) * sx
uy := (float64(py) + dy) * sy
if hasFill && inFill != nil && inFill(ux, uy) {
fillCov += 0.25
}
if hasStroke && inStroke != nil && inStroke(ux, uy) {
strokeCov += 0.25
}
}
}
if fillCov > 0 {
r.blend(px, py, fill, fillCov*opacity*attrFloat(attrs, "fill-opacity", 1))
}
if strokeCov > 0 {
r.blend(px, py, stroke, strokeCov*opacity*attrFloat(attrs, "stroke-opacity", 1))
}
}
}
}
// coverageFuncs returns point-inside tests for the fill body and the
// stroke band of an element, in user coordinates.
func coverageFuncs(el *Elem, sw float64, attrs map[string]string) (inFill, inStroke func(x, y float64) bool) {
half := sw / 2
switch el.Kind {
case "rect":
x0, y0, w, h := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
inFill = func(x, y float64) bool { return x >= x0 && x <= x0+w && y >= y0 && y <= y0+h }
inStroke = func(x, y float64) bool {
near := func(v, edge float64) bool { return math.Abs(v-edge) <= half }
inX := x >= x0-half && x <= x0+w+half
inY := y >= y0-half && y <= y0+h+half
return (inX && (near(y, y0) || near(y, y0+h))) || (inY && (near(x, x0) || near(x, x0+w)))
}
case "circle":
cx, cy, rad := el.Nums[0], el.Nums[1], el.Nums[2]
inFill = func(x, y float64) bool { return math.Hypot(x-cx, y-cy) <= rad }
inStroke = func(x, y float64) bool { return math.Abs(math.Hypot(x-cx, y-cy)-rad) <= half }
case "ellipse":
cx, cy, rx, ry := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
if rx <= 0 || ry <= 0 {
return nil, nil
}
norm := func(x, y float64) float64 {
dx, dy := (x-cx)/rx, (y-cy)/ry
return math.Sqrt(dx*dx + dy*dy)
}
inFill = func(x, y float64) bool { return norm(x, y) <= 1 }
inStroke = func(x, y float64) bool {
// approximate band by comparing scaled radial distance
n := norm(x, y)
tol := half / math.Min(rx, ry)
return math.Abs(n-1) <= tol
}
case "line":
seg := [2][2]float64{{el.Nums[0], el.Nums[1]}, {el.Nums[2], el.Nums[3]}}
inStroke = func(x, y float64) bool { return distSeg(x, y, seg[0], seg[1]) <= math.Max(half, 0.5) }
case "polyline", "polygon":
pts := el.Points
inStroke = func(x, y float64) bool {
last := len(pts) - 1
for i := 0; i < last; i++ {
if distSeg(x, y, pts[i], pts[i+1]) <= math.Max(half, 0.5) {
return true
}
}
if el.Kind == "polygon" && distSeg(x, y, pts[last], pts[0]) <= math.Max(half, 0.5) {
return true
}
return false
}
if el.Kind == "polygon" {
inFill = func(x, y float64) bool { return pointInPolygon(x, y, pts) }
}
case "path":
subs, err := flattenPath(el.D)
if err != nil {
return nil, nil
}
inStroke = func(x, y float64) bool {
for _, sp := range subs {
for i := 0; i < len(sp)-1; i++ {
if distSeg(x, y, sp[i], sp[i+1]) <= math.Max(half, 0.5) {
return true
}
}
}
return false
}
inFill = func(x, y float64) bool {
in := false
for _, sp := range subs {
if pointInPolygon(x, y, sp) {
in = !in
}
}
return in
}
case "text":
// baseline box: width ~0.6em per char, height 1em above the
// baseline, honoring text-anchor — real glyphs are out of scope
size := attrFloat(attrs, "font-size", 16)
x0, y0 := el.Nums[0], el.Nums[1]
w := 0.6 * size * float64(len([]rune(el.Text)))
switch attrs["text-anchor"] {
case "middle":
x0 -= w / 2
case "end":
x0 -= w
}
edge := math.Max(size/12, 0.75)
inFill = func(x, y float64) bool {
return x >= x0 && x <= x0+w && y >= y0-size && y <= y0 &&
(y >= y0-edge || y <= y0-size+edge || x <= x0+edge || x >= x0+w-edge)
}
}
return inFill, inStroke
}
func paint(attrs map[string]string, key, kind string) (rgba, bool) {
v := attrs[key]
if v == "" {
if key == "fill" && kind != "line" && kind != "polyline" && kind != "path" {
return rgba{0, 0, 0, 1}, true // SVG default fill is black
}
return rgba{}, false
}
if v == "none" || v == "transparent" {
return rgba{}, false
}
return parseColor(v), true
}
func attrFloat(attrs map[string]string, key string, def float64) float64 {
if v, ok := attrs[key]; ok {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func (r *Raster) blend(x, y int, c rgba, a float64) {
if a <= 0 {
return
}
if a > 1 {
a = 1
}
i := y*r.W + x
p := r.Pix[i]
p[0] = c.r*a + p[0]*(1-a)
p[1] = c.g*a + p[1]*(1-a)
p[2] = c.b*a + p[2]*(1-a)
p[3] = math.Max(p[3], a)
r.Pix[i] = p
}
func distSeg(x, y float64, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
l2 := dx*dx + dy*dy
if l2 == 0 {
return math.Hypot(x-a[0], y-a[1])
}
t := ((x-a[0])*dx + (y-a[1])*dy) / l2
t = math.Max(0, math.Min(1, t))
return math.Hypot(x-(a[0]+t*dx), y-(a[1]+t*dy))
}
func pointInPolygon(x, y float64, pts [][2]float64) bool {
in := false
n := len(pts)
for i, j := 0, n-1; i < n; j, i = i, i+1 {
xi, yi := pts[i][0], pts[i][1]
xj, yj := pts[j][0], pts[j][1]
if (yi > y) != (yj > y) && x < (xj-xi)*(y-yi)/(yj-yi)+xi {
in = !in
}
}
return in
}
// parseColor handles #rgb/#rrggbb plus the CSS names agents actually
// use; unknown names render mid-gray rather than failing the preview.
func parseColor(s string) rgba {
s = strings.TrimSpace(strings.ToLower(s))
if strings.HasPrefix(s, "#") {
h := s[1:]
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) == 6 {
r, err1 := strconv.ParseUint(h[0:2], 16, 8)
g, err2 := strconv.ParseUint(h[2:4], 16, 8)
b, err3 := strconv.ParseUint(h[4:6], 16, 8)
if err1 == nil && err2 == nil && err3 == nil {
return rgba{float64(r), float64(g), float64(b), 1}
}
}
}
if c, ok := cssColors[s]; ok {
return c
}
return rgba{128, 128, 128, 1}
}
var cssColors = map[string]rgba{
"black": {0, 0, 0, 1}, "white": {255, 255, 255, 1}, "red": {255, 0, 0, 1},
"green": {0, 128, 0, 1}, "lime": {0, 255, 0, 1}, "blue": {0, 0, 255, 1},
"yellow": {255, 255, 0, 1}, "orange": {255, 165, 0, 1}, "purple": {128, 0, 128, 1},
"gray": {128, 128, 128, 1}, "grey": {128, 128, 128, 1}, "silver": {192, 192, 192, 1},
"cyan": {0, 255, 255, 1}, "magenta": {255, 0, 255, 1}, "pink": {255, 192, 203, 1},
"brown": {165, 42, 42, 1}, "navy": {0, 0, 128, 1}, "teal": {0, 128, 128, 1},
"olive": {128, 128, 0, 1}, "maroon": {128, 0, 0, 1}, "aqua": {0, 255, 255, 1},
"fuchsia": {255, 0, 255, 1}, "gold": {255, 215, 0, 1},
}
// ANSI renders the raster with truecolor half-blocks, two pixel rows
// per text row — same technique as spritec's preview.
func (r *Raster) ANSI() string {
const reset = "\x1b[0m"
var b strings.Builder
for y := 0; y < r.H; y += 2 {
for x := 0; x < r.W; x++ {
top := r.Pix[y*r.W+x]
var bot [4]float64
if y+1 < r.H {
bot = r.Pix[(y+1)*r.W+x]
}
if top[3] == 0 && bot[3] == 0 {
b.WriteString(" ")
continue
}
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s",
int(top[0]), int(top[1]), int(top[2]),
int(bot[0]), int(bot[1]), int(bot[2]), reset)
}
b.WriteString("\n")
}
return b.String()
}

201
svg-maker/svg/svg_test.go Normal file
View File

@@ -0,0 +1,201 @@
package svg
import (
"strings"
"testing"
)
const sample = `# test scene
canvas 100 80
bg #112233
def accent #4f9cf9
rect 10 10 30 20 fill=$accent rx=3
circle 70 30 15 fill=#3fca7c stroke=white stroke-width=2
line 0 70 100 70 stroke=red width=3
polygon 10,60 30,40 50,60 fill=#e0a63f
path M60,60 L80,70 L90,50 Z stroke=white
text 50 25 "hi & <you>" size=10 fill=white anchor=middle
group stroke=gray
line 5 5 15 15
end
`
func TestParseAndEmit(t *testing.T) {
xml, doc, err := Build(sample)
if err != nil {
t.Fatal(err)
}
if doc.W != 100 || doc.H != 80 || doc.Bg != "#112233" {
t.Errorf("canvas/bg wrong: %+v", doc)
}
for _, want := range []string{
`viewBox="0 0 100 80"`,
`<rect x="10" y="10" width="30" height="20"`,
`fill="#4f9cf9"`, // $accent resolved
`rx="3"`,
`<circle cx="70" cy="30" r="15"`,
`stroke-width="3"`, // width alias
`<polygon points="10,60 30,40 50,60"`,
`<path d="M60,60 L80,70 L90,50 Z"`,
`hi &amp; &lt;you&gt;`, // escaped text
`text-anchor="middle"`,
`font-size="10"`,
`<g stroke="gray">`,
} {
if !strings.Contains(xml, want) {
t.Errorf("emitted SVG missing %q\n%s", want, xml)
}
}
}
func TestParseErrorsCarryLineNumbers(t *testing.T) {
cases := map[string]string{
"canvas 100 100\nrect 1 2 3": "line 2",
"canvas 100 100\nrect 1 2 3 four": "not a number",
"canvas 100 100\ncircle 1 2 3 glow=yes": "unknown attribute",
"canvas 100 100\nrect 1 2 3 4 fill=$missing": "undefined color variable",
"canvas 100 100\nblob 1 2": "unknown element",
"canvas 100 100\ngroup\nline 1 2 3 4": "never closed",
"canvas 100 100\nend": "end without group",
"canvas 100 100\npath X10,10": "path",
"canvas 100 100\nrect 1 2 3 4 fill=#zzz": "non-hex",
"rect 1 2 3 4": "missing canvas",
}
for src, want := range cases {
_, _, err := Build(src)
if err == nil {
t.Errorf("no error for %q", src)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("error for %q = %q, want substring %q", src, err, want)
}
}
}
func TestVisibilityDefaults(t *testing.T) {
xml, _, err := Build("canvas 10 10\nline 0 0 10 10\npolyline 0,0 5,5 10,0")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(xml, `<line x1="0" y1="0" x2="10" y2="10" stroke="black"/>`) {
t.Errorf("line did not get default stroke:\n%s", xml)
}
if !strings.Contains(xml, `fill="none"`) {
t.Errorf("polyline did not get fill=none:\n%s", xml)
}
}
func TestFlattenPath(t *testing.T) {
subs, err := flattenPath("M0,0 L10,0 V10 H0 Z")
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 {
t.Fatalf("want 1 subpath, got %d", len(subs))
}
pts := subs[0]
last := pts[len(pts)-1]
if last[0] != 0 || last[1] != 0 {
t.Errorf("Z should close back to start, ended at %v", last)
}
// curves flatten into many segments
subs, err = flattenPath("M0,0 Q50,100 100,0")
if err != nil {
t.Fatal(err)
}
if len(subs[0]) < 10 {
t.Errorf("quadratic should flatten to many points, got %d", len(subs[0]))
}
// relative commands
subs, err = flattenPath("m10,10 l10,0 l0,10 z")
if err != nil {
t.Fatal(err)
}
if got := subs[0][2]; got[0] != 20 || got[1] != 20 {
t.Errorf("relative path point = %v, want 20,20", got)
}
if _, err := flattenPath("L10,10"); err == nil {
t.Error("path not starting with M should fail")
}
if _, err := flattenPath("M0,0 A5,5 0 0 1 10,10"); err == nil {
t.Error("unsupported arc command should fail")
}
}
func TestRasterShapes(t *testing.T) {
doc, err := Parse("canvas 100 100\nbg black\ncircle 50 50 30 fill=red")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 50)
if r.W != 50 || r.H != 50 {
t.Fatalf("grid %dx%d, want 50x50", r.W, r.H)
}
center := r.Pix[25*r.W+25]
if center[0] < 200 || center[1] > 50 {
t.Errorf("center should be red, got %v", center)
}
corner := r.Pix[0]
if corner[0] > 50 && corner[1] > 50 && corner[2] > 50 {
t.Errorf("corner should be black, got %v", corner)
}
}
func TestRasterPolygonFill(t *testing.T) {
doc, err := Parse("canvas 100 100\npolygon 0,0 100,0 100,100 0,100 fill=#00ff00")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 20)
mid := r.Pix[10*r.W+10]
if mid[1] < 200 {
t.Errorf("polygon interior not filled: %v", mid)
}
}
func TestANSIPreviewShape(t *testing.T) {
doc, _ := Parse("canvas 40 20\nbg #000000\nrect 0 0 40 20 fill=white")
doc.normalize()
out := RenderGrid(doc, 40).ANSI()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 10 { // 20 pixel rows / 2 per text row
t.Errorf("ANSI preview has %d rows, want 10", len(lines))
}
if !strings.Contains(out, "▀") {
t.Error("preview contains no half-block characters")
}
}
func TestInfoWarnsOutsideCanvas(t *testing.T) {
doc, err := Parse("canvas 50 50\ncircle 25 25 10 fill=red\nrect 100 100 20 20 fill=blue\nline 40 40 60 60")
if err != nil {
t.Fatal(err)
}
doc.normalize()
info := Info(doc)
for _, want := range []string{
"canvas: 50x50",
"circle:1",
"entirely outside",
"sticks outside",
} {
if !strings.Contains(info, want) {
t.Errorf("info missing %q:\n%s", want, info)
}
}
}
func TestGroupAttrInheritanceInRaster(t *testing.T) {
doc, err := Parse("canvas 10 10\ngroup fill=#ff0000\nrect 0 0 10 10\nend")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 10)
if p := r.Pix[5*r.W+5]; p[0] < 200 {
t.Errorf("group fill not inherited: %v", p)
}
}

44
waitfor/README.md Normal file
View File

@@ -0,0 +1,44 @@
# waitfor — block until a condition holds
Replaces the hand-rolled `while ! curl …; do sleep 5; done` loops that
each need a fresh approval in an agent session with **one stable
command prefix**. The agent makes one blocking call instead of burning
turns polling. Closes the `Monitor` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.2.
## Usage
```
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
[--timeout 20m] [--then "shell command"] [--verbose]
```
The condition holds when `--cmd` exits 0 **and**, if `--matches` is
given, its combined output matches the regex. The first attempt runs
immediately; then every `--interval` until `--timeout`.
| Exit | Meaning |
|------|---------|
| 0 | condition met (`--then` runs afterwards, if given) |
| 3 | timeout — last output still printed so the caller sees the state |
| 1 | usage/config error (empty `--cmd`, bad regex) |
Examples:
```bash
# wait until Gitea answers again
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
# wait until a container is listed (read-only Pi5 wrapper)
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
# wait for a file, then notify the phone (composes with notifyr)
waitfor --cmd "test -f /tmp/report.html" --then 'notifyr send --msg "report is ready"'
```
## Build & test
```bash
go test ./...
go build -o build/waitfor .
```

3
waitfor/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/waitfor
go 1.24

86
waitfor/main.go Normal file
View File

@@ -0,0 +1,86 @@
// waitfor blocks until a shell condition holds — the agent-friendly
// replacement for hand-rolled poll loops that each need a fresh
// command approval. See doc/tool-parity.md §3.2.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
"gitea.brasse-pc.eu/brasse/agent-tools/waitfor/wait"
)
var version = "dev"
const usage = `waitfor - block until a condition holds
Usage:
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
[--timeout 20m] [--then "shell command"] [--verbose]
waitfor version
The condition holds when --cmd exits 0 and (if given) its combined
output matches --matches. The first attempt runs immediately.
Exit codes: 0 condition met, 3 timeout, 1 error. The last command
output is printed either way, so the caller always sees the state.
Examples:
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
waitfor --cmd "test -f /tmp/done" --then 'notifyr send --msg "done!"'
`
func main() {
if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Println("waitfor", version)
return
}
if len(os.Args) >= 2 && (os.Args[1] == "help" || os.Args[1] == "--help" || os.Args[1] == "-h") {
fmt.Print(usage)
return
}
fs := flag.NewFlagSet("waitfor", flag.ExitOnError)
cmd := fs.String("cmd", "", "shell command to poll (required)")
matches := fs.String("matches", "", "regex the output must match")
interval := fs.Duration("interval", 10*time.Second, "time between attempts")
timeout := fs.Duration("timeout", 10*time.Minute, "total time budget")
then := fs.String("then", "", "shell command to run when the condition is met")
verbose := fs.Bool("verbose", false, "log every attempt to stderr")
fs.Usage = func() { fmt.Fprint(os.Stderr, usage) }
fs.Parse(os.Args[1:])
if *cmd == "" {
fmt.Fprint(os.Stderr, usage)
os.Exit(1)
}
res, err := wait.Wait(wait.Options{
Cmd: *cmd, Matches: *matches, Interval: *interval, Timeout: *timeout, Verbose: *verbose,
}, wait.ShellRunner, func(s string) { fmt.Fprintln(os.Stderr, "waitfor: "+s) })
if err != nil {
fmt.Fprintln(os.Stderr, "waitfor:", err)
os.Exit(1)
}
if out := strings.TrimRight(res.LastOut, "\n"); out != "" {
fmt.Println(out)
}
if !res.Met {
fmt.Fprintf(os.Stderr, "waitfor: timeout after %s (%d attempts, last exit %d)\n",
res.Elapsed.Round(time.Second), res.Attempts, res.LastExit)
os.Exit(3)
}
fmt.Fprintf(os.Stderr, "waitfor: condition met after %s (%d attempts)\n",
res.Elapsed.Round(time.Millisecond), res.Attempts)
if *then != "" {
t := exec.Command("sh", "-c", *then)
t.Stdout, t.Stderr = os.Stdout, os.Stderr
if err := t.Run(); err != nil {
fmt.Fprintf(os.Stderr, "waitfor: --then command failed: %v\n", err)
}
}
}

94
waitfor/wait/wait.go Normal file
View File

@@ -0,0 +1,94 @@
// Package wait blocks until a shell condition holds: run a command
// every interval until it exits 0 (and, optionally, its output matches
// a regex) or a timeout expires. One blocking call instead of an
// agent burning turns on poll loops.
package wait
import (
"fmt"
"os/exec"
"regexp"
"time"
)
type Options struct {
Cmd string // shell command to run each attempt (required)
Matches string // optional regex the output must match
Interval time.Duration // between attempts
Timeout time.Duration // total budget
Verbose bool // progress line per attempt to the log func
}
type Result struct {
Met bool
Attempts int
Elapsed time.Duration
LastOut string
LastExit int
}
// Runner executes a shell command, returning combined output and exit
// code. Separated out so tests can fake it.
type Runner func(cmd string) (string, int)
// ShellRunner runs via sh -c with combined stdout+stderr.
func ShellRunner(cmd string) (string, int) {
c := exec.Command("sh", "-c", cmd)
out, err := c.CombinedOutput()
code := 0
if err != nil {
code = 1
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
return string(out), code
}
// Wait polls until the condition holds or the timeout expires. The
// first attempt runs immediately. log receives progress lines when
// Verbose is set (pass nil otherwise).
func Wait(opts Options, run Runner, log func(string)) (Result, error) {
if opts.Cmd == "" {
return Result{}, fmt.Errorf("no command given")
}
var re *regexp.Regexp
if opts.Matches != "" {
var err error
re, err = regexp.Compile(opts.Matches)
if err != nil {
return Result{}, fmt.Errorf("bad --matches regex: %v", err)
}
}
if opts.Interval <= 0 {
opts.Interval = 10 * time.Second
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Minute
}
start := time.Now()
res := Result{}
for {
res.Attempts++
out, code := run(opts.Cmd)
res.LastOut, res.LastExit = out, code
met := code == 0 && (re == nil || re.MatchString(out))
if opts.Verbose && log != nil {
state := "not yet"
if met {
state = "met"
}
log(fmt.Sprintf("attempt %d: exit %d, condition %s", res.Attempts, code, state))
}
if met {
res.Met = true
res.Elapsed = time.Since(start)
return res, nil
}
if time.Since(start)+opts.Interval > opts.Timeout {
res.Elapsed = time.Since(start)
return res, nil
}
time.Sleep(opts.Interval)
}
}

92
waitfor/wait/wait_test.go Normal file
View File

@@ -0,0 +1,92 @@
package wait
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestMetOnExitZero(t *testing.T) {
calls := 0
run := func(cmd string) (string, int) {
calls++
if calls < 3 {
return "not ready", 1
}
return "ready", 0
}
res, err := Wait(Options{Cmd: "x", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met || res.Attempts != 3 || res.LastOut != "ready" {
t.Errorf("unexpected result: %+v", res)
}
}
func TestMatchesRequiredOnTopOfExitZero(t *testing.T) {
calls := 0
run := func(cmd string) (string, int) {
calls++
if calls == 1 {
return "gitea starting", 0 // exit 0 but no match yet
}
return "gitea healthy", 0
}
res, err := Wait(Options{Cmd: "x", Matches: "healthy", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met || res.Attempts != 2 {
t.Errorf("match should gate success: %+v", res)
}
}
func TestTimeoutReportsLastOutput(t *testing.T) {
run := func(cmd string) (string, int) { return "still broken", 7 }
res, err := Wait(Options{Cmd: "x", Interval: 5 * time.Millisecond, Timeout: 20 * time.Millisecond}, run, nil)
if err != nil {
t.Fatal(err)
}
if res.Met {
t.Error("should have timed out")
}
if res.LastOut != "still broken" || res.LastExit != 7 {
t.Errorf("last output lost: %+v", res)
}
if res.Attempts < 1 {
t.Error("should have tried at least once")
}
}
func TestBadRegexRejected(t *testing.T) {
if _, err := Wait(Options{Cmd: "x", Matches: "("}, nil, nil); err == nil {
t.Error("bad regex accepted")
}
}
func TestEmptyCommandRejected(t *testing.T) {
if _, err := Wait(Options{}, nil, nil); err == nil {
t.Error("empty command accepted")
}
}
// Integration: real shell, waiting for a file to appear (the exact
// case from the agy live test in doc/tool-parity.md).
func TestShellRunnerFileAppears(t *testing.T) {
path := filepath.Join(t.TempDir(), "flag")
go func() {
time.Sleep(30 * time.Millisecond)
os.WriteFile(path, []byte("x"), 0o644)
}()
res, err := Wait(Options{
Cmd: "test -f " + path, Interval: 10 * time.Millisecond, Timeout: 2 * time.Second,
}, ShellRunner, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met {
t.Errorf("file never seen: %+v", res)
}
}