11 Commits

Author SHA1 Message Date
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
54 changed files with 37676 additions and 1 deletions

View File

@@ -0,0 +1,109 @@
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"
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 ;;
*) 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 ;;
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

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

@@ -0,0 +1,76 @@
{
"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": "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"],
"dependsOrder": "parallel",
"group": { "kind": "build", "isDefault": true },
"problemMatcher": []
}
]
}

View File

@@ -1,3 +1,48 @@
# 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…). |
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>`).
## 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)
}

43
doc/plan.md Normal file
View File

@@ -0,0 +1,43 @@
# 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.
## Open questions
- Versioned releases (`vX.Y.Z` tags per tool) on top of the rolling
`latest` — add when something depends on a pinned version.

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,
}
}

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
}