6 Commits

Author SHA1 Message Date
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
23135a96d1 pixel-sprite-maker: spritec - .sprite text format -> png/jpg/svg + sprite sheets
- .sprite format: single-char palette keys (hex/rgb()/CSS names/none), grid
  rows, '.' transparent by default, max 256x256 per sprite
- render/sheet/info/preview subcommands; sheets name themselves
  <base>_<cellW>x<cellH>_<cols>x<rows>.<ext> (row-major)
- SVG output RLE-merges pixel runs; JPG composites over --bg
- go tests for parser, colors, scaling, svg, sheet layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 02:22:16 +02:00
43 changed files with 36497 additions and 1 deletions

View File

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/
*.exe
mesh-tool/examples/downloads/

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

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

View File

@@ -1,3 +1,46 @@
# 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. |
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

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

View File

@@ -0,0 +1,86 @@
# pixel-sprite-maker (`spritec`)
Turns human/agent-readable `.sprite` text files into pixel art (**PNG, JPG, SVG**)
and combines several sprites into **sprite sheets / animation strips**.
Written in Go, zero dependencies, single static binary.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd pixel-sprite-maker
go build -o build/spritec . # or run the VS Code task "build pixel-sprite-maker"
go test ./...
```
## The `.sprite` format
One file = one sprite. Designed so an agent can *see* the result in the text:
```
# comment lines start with '#'
sprite: coin # optional name
palette:
k = #1A1205 dark outline
y = #FFD700 gold
Y = gold CSS names work too — text after the color is a comment
grid:
..kkkk..
.kyyyYk.
kyyyYYyk
..kkkk..
```
Rules:
- **One character = one pixel.** Every grid row must be the same width.
- Palette keys are exactly one character (any unicode). `#`, `=`, `:` and
whitespace are not allowed as keys — so the number of colors is
practically unlimited (a-z, A-Z, 0-9, `!@%&*`, unicode…).
- `.` is **transparent by default**; override it in the palette if you want.
- Colors: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`,
`rgba(r,g,b,a)` (alpha 0-255 or 0.0-1.0), all CSS named colors, `none`.
- Max **256x256** pixels per sprite. Sheets may exceed this; single frames may not.
## Commands
```bash
spritec render coin.sprite -o coin.png --scale 8 # png/jpg/svg from extension
spritec render coin.sprite --format svg # -> coin.svg
spritec render heart.sprite -o heart.jpg --bg '#222034' # jpg has no alpha: pick bg
spritec info coin.sprite # validate + palette stats
spritec preview coin.sprite # draw in the terminal (truecolor)
# Sprite sheets / animations — row-major (left→right, then top→bottom):
spritec sheet walk_1.sprite walk_2.sprite walk_3.sprite walk_4.sprite -o walk # 1 row
spritec sheet walk_*.sprite --cols 2 -o walkgrid # 2D: 2 columns x 2 rows
```
Flags: `-o` output, `--format png|jpg|svg`, `--scale n` (integer nearest-neighbour
upscale), `--bg color` + `--quality 1-100` (jpg only), `--cols n` (sheet only).
## Sheet output naming — read the layout from the file name
```
<name>_<cellW>x<cellH>_<cols>x<rows>.<ext>
walk_8x8_4x1.png = 8x8 px per frame, 4 columns, 1 row (animation strip)
tiles_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows
```
Frame order is always **row-major**: index = `row * cols + col`, frame 0 is
top-left. If the frame count doesn't fill the grid, trailing cells are
transparent. In game code:
```
frameX = (index % cols) * cellW
frameY = (index / cols) * cellH
```
## Exit codes & errors
`0` on success, `1` on any error, `2` on missing command. Parse errors name
the exact line/row/column and what was expected, so an agent can fix the
file without guessing.
Examples live in [`examples/`](examples/); generated output in `examples/out/`.

View File

@@ -0,0 +1,17 @@
# A small gold coin, 8x8.
# '.' is transparent by default and never needs a palette entry.
sprite: coin
palette:
k = #1A1205 dark outline
y = #FFD700 gold
Y = #FFF3A0 highlight
s = #B8860B shadow
grid:
..kkkk..
.kyyyYk.
kyyyYYsk
kyyYyysk
kyYyyysk
kYyyyssk
.kysssk.
..kkkk..

View File

@@ -0,0 +1,25 @@
# A 16x16 heart with shading, shows named colors and rgb().
sprite: heart
palette:
k = #2B0A10 outline
r = crimson main red
R = rgb(255, 90, 110) light red
d = #8B1A2B dark red
w = #FFFFFFCC semi-transparent shine
grid:
................
..kkk....kkk....
.krrRk..kRrrk...
krrRRrkkrRrrrk..
krRwRrrrrrrrdk..
krRwwRrrrrrrdk..
krRwRrrrrrrddk..
krrRrrrrrrrddk..
.krrrrrrrrrdk...
..krrrrrrrdk....
...krrrrrdk.....
....krrrdk......
.....krdk.......
......kk........
................
................

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges">
<rect x="2" y="0" width="4" height="1" fill="#1a1205"/>
<rect x="1" y="1" width="1" height="1" fill="#1a1205"/>
<rect x="2" y="1" width="3" height="1" fill="#ffd700"/>
<rect x="5" y="1" width="1" height="1" fill="#fff3a0"/>
<rect x="6" y="1" width="1" height="1" fill="#1a1205"/>
<rect x="0" y="2" width="1" height="1" fill="#1a1205"/>
<rect x="1" y="2" width="3" height="1" fill="#ffd700"/>
<rect x="4" y="2" width="2" height="1" fill="#fff3a0"/>
<rect x="6" y="2" width="1" height="1" fill="#b8860b"/>
<rect x="7" y="2" width="1" height="1" fill="#1a1205"/>
<rect x="0" y="3" width="1" height="1" fill="#1a1205"/>
<rect x="1" y="3" width="2" height="1" fill="#ffd700"/>
<rect x="3" y="3" width="1" height="1" fill="#fff3a0"/>
<rect x="4" y="3" width="2" height="1" fill="#ffd700"/>
<rect x="6" y="3" width="1" height="1" fill="#b8860b"/>
<rect x="7" y="3" width="1" height="1" fill="#1a1205"/>
<rect x="0" y="4" width="1" height="1" fill="#1a1205"/>
<rect x="1" y="4" width="1" height="1" fill="#ffd700"/>
<rect x="2" y="4" width="1" height="1" fill="#fff3a0"/>
<rect x="3" y="4" width="3" height="1" fill="#ffd700"/>
<rect x="6" y="4" width="1" height="1" fill="#b8860b"/>
<rect x="7" y="4" width="1" height="1" fill="#1a1205"/>
<rect x="0" y="5" width="1" height="1" fill="#1a1205"/>
<rect x="1" y="5" width="1" height="1" fill="#fff3a0"/>
<rect x="2" y="5" width="3" height="1" fill="#ffd700"/>
<rect x="5" y="5" width="2" height="1" fill="#b8860b"/>
<rect x="7" y="5" width="1" height="1" fill="#1a1205"/>
<rect x="1" y="6" width="1" height="1" fill="#1a1205"/>
<rect x="2" y="6" width="1" height="1" fill="#ffd700"/>
<rect x="3" y="6" width="3" height="1" fill="#b8860b"/>
<rect x="6" y="6" width="1" height="1" fill="#1a1205"/>
<rect x="2" y="7" width="4" height="1" fill="#1a1205"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" shape-rendering="crispEdges">
<rect x="2" y="0" width="3" height="1" fill="#101018"/>
<rect x="10" y="0" width="3" height="1" fill="#101018"/>
<rect x="2" y="1" width="1" height="1" fill="#101018"/>
<rect x="3" y="1" width="2" height="1" fill="#f2c09a"/>
<rect x="10" y="1" width="1" height="1" fill="#101018"/>
<rect x="11" y="1" width="2" height="1" fill="#f2c09a"/>
<rect x="2" y="2" width="1" height="1" fill="#101018"/>
<rect x="3" y="2" width="2" height="1" fill="#f2c09a"/>
<rect x="10" y="2" width="1" height="1" fill="#101018"/>
<rect x="11" y="2" width="2" height="1" fill="#f2c09a"/>
<rect x="1" y="3" width="1" height="1" fill="#101018"/>
<rect x="2" y="3" width="3" height="1" fill="#2e5fb7"/>
<rect x="5" y="3" width="1" height="1" fill="#101018"/>
<rect x="9" y="3" width="1" height="1" fill="#101018"/>
<rect x="10" y="3" width="3" height="1" fill="#2e5fb7"/>
<rect x="13" y="3" width="1" height="1" fill="#101018"/>
<rect x="2" y="4" width="3" height="1" fill="#2e5fb7"/>
<rect x="10" y="4" width="3" height="1" fill="#2e5fb7"/>
<rect x="2" y="5" width="3" height="1" fill="#22406e"/>
<rect x="10" y="5" width="3" height="1" fill="#22406e"/>
<rect x="2" y="6" width="1" height="1" fill="#22406e"/>
<rect x="4" y="6" width="1" height="1" fill="#22406e"/>
<rect x="9" y="6" width="1" height="1" fill="#22406e"/>
<rect x="13" y="6" width="1" height="1" fill="#22406e"/>
<rect x="2" y="7" width="1" height="1" fill="#101018"/>
<rect x="4" y="7" width="1" height="1" fill="#101018"/>
<rect x="9" y="7" width="1" height="1" fill="#101018"/>
<rect x="13" y="7" width="1" height="1" fill="#101018"/>
<rect x="2" y="8" width="3" height="1" fill="#101018"/>
<rect x="10" y="8" width="3" height="1" fill="#101018"/>
<rect x="2" y="9" width="1" height="1" fill="#101018"/>
<rect x="3" y="9" width="2" height="1" fill="#f2c09a"/>
<rect x="10" y="9" width="1" height="1" fill="#101018"/>
<rect x="11" y="9" width="2" height="1" fill="#f2c09a"/>
<rect x="2" y="10" width="1" height="1" fill="#101018"/>
<rect x="3" y="10" width="2" height="1" fill="#f2c09a"/>
<rect x="10" y="10" width="1" height="1" fill="#101018"/>
<rect x="11" y="10" width="2" height="1" fill="#f2c09a"/>
<rect x="2" y="11" width="3" height="1" fill="#2e5fb7"/>
<rect x="5" y="11" width="1" height="1" fill="#101018"/>
<rect x="9" y="11" width="1" height="1" fill="#101018"/>
<rect x="10" y="11" width="3" height="1" fill="#2e5fb7"/>
<rect x="13" y="11" width="1" height="1" fill="#101018"/>
<rect x="1" y="12" width="1" height="1" fill="#101018"/>
<rect x="2" y="12" width="3" height="1" fill="#2e5fb7"/>
<rect x="10" y="12" width="3" height="1" fill="#2e5fb7"/>
<rect x="2" y="13" width="3" height="1" fill="#22406e"/>
<rect x="10" y="13" width="3" height="1" fill="#22406e"/>
<rect x="2" y="14" width="1" height="1" fill="#22406e"/>
<rect x="4" y="14" width="1" height="1" fill="#22406e"/>
<rect x="10" y="14" width="2" height="1" fill="#22406e"/>
<rect x="2" y="15" width="1" height="1" fill="#101018"/>
<rect x="4" y="15" width="1" height="1" fill="#101018"/>
<rect x="9" y="15" width="1" height="1" fill="#101018"/>
<rect x="12" y="15" width="1" height="1" fill="#101018"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,16 @@
# Frame 1/4 of a tiny walking guy, 8x8. Legs together.
sprite: walk_1
palette:
k = #101018 outline / hair
s = #F2C09A skin
b = #2E5FB7 shirt
d = #22406E pants
grid:
..kkk...
..kss...
..kss...
.kbbbk..
..bbb...
..ddd...
..d.d...
..k.k...

View File

@@ -0,0 +1,16 @@
# Frame 2/4 - right leg forward.
sprite: walk_2
palette:
k = #101018 outline / hair
s = #F2C09A skin
b = #2E5FB7 shirt
d = #22406E pants
grid:
..kkk...
..kss...
..kss...
.kbbbk..
..bbb...
..ddd...
.d...d..
.k...k..

View File

@@ -0,0 +1,16 @@
# Frame 3/4 - legs together again (same pose as 1, arms swung).
sprite: walk_3
palette:
k = #101018 outline / hair
s = #F2C09A skin
b = #2E5FB7 shirt
d = #22406E pants
grid:
..kkk...
..kss...
..kss...
..bbbk..
.kbbb...
..ddd...
..d.d...
..k.k...

View File

@@ -0,0 +1,16 @@
# Frame 4/4 - left leg forward.
sprite: walk_4
palette:
k = #101018 outline / hair
s = #F2C09A skin
b = #2E5FB7 shirt
d = #22406E pants
grid:
..kkk...
..kss...
..kss...
.kbbbk..
..bbb...
..ddd...
..dd....
.k..k...

View File

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

313
pixel-sprite-maker/main.go Normal file
View File

@@ -0,0 +1,313 @@
// spritec turns agent-friendly .sprite text files into PNG/JPG/SVG pixel
// art, and combines several sprites into sprite sheets / animation strips.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/pixel-sprite-maker/sprite"
)
var version = "dev"
const usage = `spritec - pixel sprite maker for agents
Usage:
spritec render <file.sprite> [flags] render one sprite to png/jpg/svg
spritec sheet <a.sprite> <b.sprite> ... [flags]
combine sprites into one sheet image
spritec info <file.sprite> validate a file and print its stats
spritec preview <file.sprite> draw the sprite in the terminal
spritec version print version
Render flags:
-o <path> output file; its extension picks the format (.png .jpg .svg)
--format <f> png | jpg | svg (default: from -o, else png)
--scale <n> integer upscale factor, default 1
--bg <color> jpg background color (jpg has no alpha), default #FFFFFF
--quality <n> jpg quality 1-100, default 90
Sheet flags (in addition to the render flags):
-o <name> output base name; layout is appended automatically
--cols <n> number of columns, row-major order (default: one row)
Sheet output naming:
<name>_<cellW>x<cellH>_<cols>x<rows>.<ext>
e.g. walk_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows,
frames are read left-to-right then top-to-bottom (row-major).
The .sprite format:
# comment
sprite: coin optional name
palette:
. = none '.' is transparent by default
k = #000000 hex, rgb(...), CSS names ('gold') and 'none' work
y = gold anything after the color is a comment
grid:
..kk..
.kyyk.
.kyyk.
..kk..
Each grid character is one pixel; every row must be the same width.
Max sprite size: 256x256. Sheets may be bigger, single frames may not.
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "render":
cmdRender(os.Args[2:])
case "sheet":
cmdSheet(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("spritec", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'spritec help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args
// (stdlib flag stops at the first positional otherwise).
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...))
}
type renderFlags struct {
out string
format string
scale int
bg string
quality int
}
func addRenderFlags(fs *flag.FlagSet) *renderFlags {
rf := &renderFlags{}
fs.StringVar(&rf.out, "o", "", "output path")
fs.StringVar(&rf.format, "format", "", "png|jpg|svg")
fs.IntVar(&rf.scale, "scale", 1, "integer upscale factor")
fs.StringVar(&rf.bg, "bg", "#FFFFFF", "jpg background color")
fs.IntVar(&rf.quality, "quality", 90, "jpg quality 1-100")
return rf
}
// resolveFormat picks the output format from --format or the -o extension.
func (rf *renderFlags) resolveFormat() (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(rf.out), "."))
if ext == "jpeg" {
ext = "jpg"
}
f := strings.ToLower(rf.format)
switch {
case f == "" && ext == "":
return "png", nil
case f == "":
f = ext
case ext != "" && ext != f:
return "", fmt.Errorf("--format %s conflicts with output extension .%s", f, ext)
}
switch f {
case "png", "jpg", "svg":
return f, nil
}
return "", fmt.Errorf("unsupported format %q (png, jpg or svg)", f)
}
func writeGrid(path, format string, g sprite.PixelGrid, rf *renderFlags) error {
bg, err := sprite.ParseColor(rf.bg)
if err != nil {
return fmt.Errorf("--bg: %w", err)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
switch format {
case "png":
err = sprite.WritePNG(f, g, rf.scale)
case "jpg":
err = sprite.WriteJPG(f, g, rf.scale, bg, rf.quality)
case "svg":
err = sprite.WriteSVG(f, g, rf.scale)
}
if err != nil {
return err
}
return f.Close()
}
func cmdRender(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
rf := addRenderFlags(fs)
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("render takes exactly one .sprite file")
}
in := fs.Arg(0)
s, err := sprite.ParseFile(in)
if err != nil {
die("%v", err)
}
format, err := rf.resolveFormat()
if err != nil {
die("%v", err)
}
out := rf.out
if out == "" {
out = strings.TrimSuffix(in, filepath.Ext(in)) + "." + format
}
if err := writeGrid(out, format, s, rf); err != nil {
die("%v", err)
}
w, h := s.Bounds()
fmt.Printf("%s (%dx%d px, scale %d -> %dx%d)\n", out, w, h, rf.scale, w*rf.scale, h*rf.scale)
}
func cmdSheet(args []string) {
fs := flag.NewFlagSet("sheet", flag.ExitOnError)
rf := addRenderFlags(fs)
cols := fs.Int("cols", 0, "columns in the sheet (default: all sprites in one row)")
parseInterspersed(fs, args)
if fs.NArg() < 1 {
die("sheet needs at least one .sprite file")
}
var sprites []*sprite.Sprite
for _, path := range fs.Args() {
s, err := sprite.ParseFile(path)
if err != nil {
die("%v", err)
}
sprites = append(sprites, s)
}
sh, err := sprite.NewSheet(sprites, *cols)
if err != nil {
die("%v", err)
}
format, err := rf.resolveFormat()
if err != nil {
die("%v", err)
}
base := rf.out
if base == "" {
base = "sheet"
}
base = strings.TrimSuffix(base, filepath.Ext(base))
out := sh.FileBase(base) + "." + format
if err := writeGrid(out, format, sh, rf); err != nil {
die("%v", err)
}
w, h := sh.Bounds()
fmt.Printf("%s (%d frames of %dx%d px in %dx%d grid, total %dx%d px)\n",
out, len(sprites), sh.CellW, sh.CellH, sh.Cols, sh.Rows, w*rf.scale, h*rf.scale)
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .sprite file")
}
s, err := sprite.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("sprite: %s\n", s.Name)
fmt.Printf("size: %dx%d px\n", s.W, s.H)
fmt.Printf("palette: %d colors\n", len(s.Keys))
usage := s.UsageCount()
for _, k := range s.Keys {
note := ""
if usage[k] == 0 {
note = " (unused)"
}
fmt.Printf(" %s = %-9s %5d px%s\n", string(k), sprite.FormatColor(s.Palette[k]), usage[k], note)
}
if _, defined := usage['.']; defined && !contains(s.Keys, '.') {
fmt.Printf(" . = none %5d px (implicit transparent)\n", usage['.'])
}
fmt.Println("valid: yes")
}
func contains(keys []rune, k rune) bool {
for _, x := range keys {
if x == k {
return true
}
}
return false
}
// cmdPreview draws the sprite with truecolor half-blocks, two pixel rows
// per terminal line. Transparent pixels show the terminal background.
func cmdPreview(args []string) {
if len(args) != 1 {
die("preview takes exactly one .sprite file")
}
s, err := sprite.ParseFile(args[0])
if err != nil {
die("%v", err)
}
const reset = "\x1b[0m"
var b strings.Builder
for y := 0; y < s.H; y += 2 {
for x := 0; x < s.W; x++ {
top, bot := s.At(x, y), sprite.Transparent
if y+1 < s.H {
bot = s.At(x, y+1)
}
topOn, botOn := top.A >= 128, bot.A >= 128
switch {
case topOn && botOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s", top.R, top.G, top.B, bot.R, bot.G, bot.B, reset)
case topOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▀%s", top.R, top.G, top.B, reset)
case botOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▄%s", bot.R, bot.G, bot.B, reset)
default:
b.WriteByte(' ')
}
}
b.WriteByte('\n')
}
fmt.Printf("%s %dx%d px\n%s", s.Name, s.W, s.H, b.String())
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "spritec: "+format+"\n", a...)
os.Exit(1)
}

View File

@@ -0,0 +1,270 @@
package sprite
import (
"fmt"
"image/color"
"strconv"
"strings"
)
// Transparent is the color used for pixels that should not be drawn.
var Transparent = color.NRGBA{0, 0, 0, 0}
// ParseColor accepts:
//
// none | transparent | - -> fully transparent
// #RGB #RGBA #RRGGBB #RRGGBBAA -> hex
// rgb(r,g,b) rgba(r,g,b,a) -> 0-255 channels, alpha 0-255 or 0.0-1.0
// CSS named colors -> "red", "rebeccapurple", ...
func ParseColor(s string) (color.NRGBA, error) {
t := strings.ToLower(strings.TrimSpace(s))
if t == "" {
return Transparent, fmt.Errorf("empty color value")
}
switch t {
case "none", "transparent", "-":
return Transparent, nil
}
if strings.HasPrefix(t, "#") {
return parseHex(t)
}
if strings.HasPrefix(t, "rgb(") || strings.HasPrefix(t, "rgba(") {
return parseRGBFunc(t)
}
if c, ok := cssColors[t]; ok {
return c, nil
}
return Transparent, fmt.Errorf("unknown color %q (use #RRGGBB, rgb(r,g,b), a CSS color name, or 'none')", s)
}
func parseHex(t string) (color.NRGBA, error) {
h := t[1:]
var r, g, b, a uint64
var err error
dup := func(s string) (uint64, error) {
v, err := strconv.ParseUint(s, 16, 8)
return v*16 + v, err
}
a = 255
switch len(h) {
case 3, 4:
if r, err = dup(h[0:1]); err == nil {
if g, err = dup(h[1:2]); err == nil {
b, err = dup(h[2:3])
}
}
if err == nil && len(h) == 4 {
a, err = dup(h[3:4])
}
case 6, 8:
if r, err = strconv.ParseUint(h[0:2], 16, 8); err == nil {
if g, err = strconv.ParseUint(h[2:4], 16, 8); err == nil {
b, err = strconv.ParseUint(h[4:6], 16, 8)
}
}
if err == nil && len(h) == 8 {
a, err = strconv.ParseUint(h[6:8], 16, 8)
}
default:
return Transparent, fmt.Errorf("hex color %q must be #RGB, #RGBA, #RRGGBB or #RRGGBBAA", t)
}
if err != nil {
return Transparent, fmt.Errorf("invalid hex color %q", t)
}
return color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)}, nil
}
func parseRGBFunc(t string) (color.NRGBA, error) {
open := strings.Index(t, "(")
close := strings.Index(t, ")")
if close < open {
return Transparent, fmt.Errorf("invalid color %q: missing ')'", t)
}
parts := strings.Split(t[open+1:close], ",")
if len(parts) != 3 && len(parts) != 4 {
return Transparent, fmt.Errorf("invalid color %q: want rgb(r,g,b) or rgba(r,g,b,a)", t)
}
var ch [4]uint8
ch[3] = 255
for i, p := range parts {
p = strings.TrimSpace(p)
if i == 3 && strings.Contains(p, ".") {
// CSS-style fractional alpha 0.0 - 1.0
f, err := strconv.ParseFloat(p, 64)
if err != nil || f < 0 || f > 1 {
return Transparent, fmt.Errorf("invalid alpha %q in %q (want 0.0-1.0 or 0-255)", p, t)
}
ch[3] = uint8(f*255 + 0.5)
continue
}
v, err := strconv.ParseUint(p, 10, 8)
if err != nil {
return Transparent, fmt.Errorf("invalid channel %q in %q (want 0-255)", p, t)
}
ch[i] = uint8(v)
}
return color.NRGBA{ch[0], ch[1], ch[2], ch[3]}, nil
}
// FormatColor renders a color the way it should appear in a .sprite file.
func FormatColor(c color.NRGBA) string {
if c.A == 0 {
return "none"
}
if c.A == 255 {
return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B)
}
return fmt.Sprintf("#%02X%02X%02X%02X", c.R, c.G, c.B, c.A)
}
// cssColors is the full CSS Color Module Level 4 named-color list.
var cssColors = map[string]color.NRGBA{
"aliceblue": {240, 248, 255, 255},
"antiquewhite": {250, 235, 215, 255},
"aqua": {0, 255, 255, 255},
"aquamarine": {127, 255, 212, 255},
"azure": {240, 255, 255, 255},
"beige": {245, 245, 220, 255},
"bisque": {255, 228, 196, 255},
"black": {0, 0, 0, 255},
"blanchedalmond": {255, 235, 205, 255},
"blue": {0, 0, 255, 255},
"blueviolet": {138, 43, 226, 255},
"brown": {165, 42, 42, 255},
"burlywood": {222, 184, 135, 255},
"cadetblue": {95, 158, 160, 255},
"chartreuse": {127, 255, 0, 255},
"chocolate": {210, 105, 30, 255},
"coral": {255, 127, 80, 255},
"cornflowerblue": {100, 149, 237, 255},
"cornsilk": {255, 248, 220, 255},
"crimson": {220, 20, 60, 255},
"cyan": {0, 255, 255, 255},
"darkblue": {0, 0, 139, 255},
"darkcyan": {0, 139, 139, 255},
"darkgoldenrod": {184, 134, 11, 255},
"darkgray": {169, 169, 169, 255},
"darkgreen": {0, 100, 0, 255},
"darkgrey": {169, 169, 169, 255},
"darkkhaki": {189, 183, 107, 255},
"darkmagenta": {139, 0, 139, 255},
"darkolivegreen": {85, 107, 47, 255},
"darkorange": {255, 140, 0, 255},
"darkorchid": {153, 50, 204, 255},
"darkred": {139, 0, 0, 255},
"darksalmon": {233, 150, 122, 255},
"darkseagreen": {143, 188, 143, 255},
"darkslateblue": {72, 61, 139, 255},
"darkslategray": {47, 79, 79, 255},
"darkslategrey": {47, 79, 79, 255},
"darkturquoise": {0, 206, 209, 255},
"darkviolet": {148, 0, 211, 255},
"deeppink": {255, 20, 147, 255},
"deepskyblue": {0, 191, 255, 255},
"dimgray": {105, 105, 105, 255},
"dimgrey": {105, 105, 105, 255},
"dodgerblue": {30, 144, 255, 255},
"firebrick": {178, 34, 34, 255},
"floralwhite": {255, 250, 240, 255},
"forestgreen": {34, 139, 34, 255},
"fuchsia": {255, 0, 255, 255},
"gainsboro": {220, 220, 220, 255},
"ghostwhite": {248, 248, 255, 255},
"gold": {255, 215, 0, 255},
"goldenrod": {218, 165, 32, 255},
"gray": {128, 128, 128, 255},
"green": {0, 128, 0, 255},
"greenyellow": {173, 255, 47, 255},
"grey": {128, 128, 128, 255},
"honeydew": {240, 255, 240, 255},
"hotpink": {255, 105, 180, 255},
"indianred": {205, 92, 92, 255},
"indigo": {75, 0, 130, 255},
"ivory": {255, 255, 240, 255},
"khaki": {240, 230, 140, 255},
"lavender": {230, 230, 250, 255},
"lavenderblush": {255, 240, 245, 255},
"lawngreen": {124, 252, 0, 255},
"lemonchiffon": {255, 250, 205, 255},
"lightblue": {173, 216, 230, 255},
"lightcoral": {240, 128, 128, 255},
"lightcyan": {224, 255, 255, 255},
"lightgoldenrodyellow": {250, 250, 210, 255},
"lightgray": {211, 211, 211, 255},
"lightgreen": {144, 238, 144, 255},
"lightgrey": {211, 211, 211, 255},
"lightpink": {255, 182, 193, 255},
"lightsalmon": {255, 160, 122, 255},
"lightseagreen": {32, 178, 170, 255},
"lightskyblue": {135, 206, 250, 255},
"lightslategray": {119, 136, 153, 255},
"lightslategrey": {119, 136, 153, 255},
"lightsteelblue": {176, 196, 222, 255},
"lightyellow": {255, 255, 224, 255},
"lime": {0, 255, 0, 255},
"limegreen": {50, 205, 50, 255},
"linen": {250, 240, 230, 255},
"magenta": {255, 0, 255, 255},
"maroon": {128, 0, 0, 255},
"mediumaquamarine": {102, 205, 170, 255},
"mediumblue": {0, 0, 205, 255},
"mediumorchid": {186, 85, 211, 255},
"mediumpurple": {147, 112, 219, 255},
"mediumseagreen": {60, 179, 113, 255},
"mediumslateblue": {123, 104, 238, 255},
"mediumspringgreen": {0, 250, 154, 255},
"mediumturquoise": {72, 209, 204, 255},
"mediumvioletred": {199, 21, 133, 255},
"midnightblue": {25, 25, 112, 255},
"mintcream": {245, 255, 250, 255},
"mistyrose": {255, 228, 225, 255},
"moccasin": {255, 228, 181, 255},
"navajowhite": {255, 222, 173, 255},
"navy": {0, 0, 128, 255},
"oldlace": {253, 245, 230, 255},
"olive": {128, 128, 0, 255},
"olivedrab": {107, 142, 35, 255},
"orange": {255, 165, 0, 255},
"orangered": {255, 69, 0, 255},
"orchid": {218, 112, 214, 255},
"palegoldenrod": {238, 232, 170, 255},
"palegreen": {152, 251, 152, 255},
"paleturquoise": {175, 238, 238, 255},
"palevioletred": {219, 112, 147, 255},
"papayawhip": {255, 239, 213, 255},
"peachpuff": {255, 218, 185, 255},
"peru": {205, 133, 63, 255},
"pink": {255, 192, 203, 255},
"plum": {221, 160, 221, 255},
"powderblue": {176, 224, 230, 255},
"purple": {128, 0, 128, 255},
"rebeccapurple": {102, 51, 153, 255},
"red": {255, 0, 0, 255},
"rosybrown": {188, 143, 143, 255},
"royalblue": {65, 105, 225, 255},
"saddlebrown": {139, 69, 19, 255},
"salmon": {250, 128, 114, 255},
"sandybrown": {244, 164, 96, 255},
"seagreen": {46, 139, 87, 255},
"seashell": {255, 245, 238, 255},
"sienna": {160, 82, 45, 255},
"silver": {192, 192, 192, 255},
"skyblue": {135, 206, 235, 255},
"slateblue": {106, 90, 205, 255},
"slategray": {112, 128, 144, 255},
"slategrey": {112, 128, 144, 255},
"snow": {255, 250, 250, 255},
"springgreen": {0, 255, 127, 255},
"steelblue": {70, 130, 180, 255},
"tan": {210, 180, 140, 255},
"teal": {0, 128, 128, 255},
"thistle": {216, 191, 216, 255},
"tomato": {255, 99, 71, 255},
"turquoise": {64, 224, 208, 255},
"violet": {238, 130, 238, 255},
"wheat": {245, 222, 179, 255},
"white": {255, 255, 255, 255},
"whitesmoke": {245, 245, 245, 255},
"yellow": {255, 255, 0, 255},
"yellowgreen": {154, 205, 50, 255},
}

View File

@@ -0,0 +1,186 @@
package sprite
import (
"bufio"
"fmt"
"image/color"
"io"
"os"
"path/filepath"
"strings"
)
// MaxSize is the maximum width/height of a single sprite in pixels.
const MaxSize = 256
// Sprite is a parsed .sprite file: a palette of single-rune keys and a
// rectangular grid of those keys.
type Sprite struct {
Name string
W, H int
Palette map[rune]color.NRGBA
Keys []rune // palette keys in file order
Rows [][]rune // H rows of exactly W palette keys
}
// At returns the color of pixel (x, y). Out-of-range pixels are transparent.
func (s *Sprite) At(x, y int) color.NRGBA {
if x < 0 || y < 0 || x >= s.W || y >= s.H {
return Transparent
}
return s.Palette[s.Rows[y][x]]
}
// Bounds returns the sprite size in pixels.
func (s *Sprite) Bounds() (w, h int) { return s.W, s.H }
// ParseFile reads a .sprite file from disk. The sprite name defaults to the
// file name without extension when the file has no "sprite:" line.
func ParseFile(path string) (*Sprite, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
s, err := Parse(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if s.Name == "" {
s.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
return s, nil
}
// Parse reads the .sprite text format:
//
// # comment lines start with '#'
// sprite: coin (optional name)
// palette:
// . = none (key '.' is transparent by default)
// k = #000000 text after the color is ignored
// y = gold
// grid:
// ..kk..
// .kyyk.
//
// Palette keys are exactly one character and may not be '#', '=', ':' or
// whitespace. Every grid row must be the same width; max size is 256x256.
func Parse(r io.Reader) (*Sprite, error) {
s := &Sprite{Palette: map[rune]color.NRGBA{}}
const (
secNone = iota
secPalette
secGrid
)
section := secNone
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNo := 0
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue // comment or blank ('#' is not a legal palette key)
}
lower := strings.ToLower(line)
switch {
case lower == "palette:":
section = secPalette
continue
case lower == "grid:":
section = secGrid
continue
case strings.HasPrefix(lower, "sprite:"):
s.Name = strings.TrimSpace(line[len("sprite:"):])
continue
}
switch section {
case secPalette:
if err := s.parsePaletteLine(line, lineNo); err != nil {
return nil, err
}
case secGrid:
row := []rune(line)
s.Rows = append(s.Rows, row)
default:
return nil, fmt.Errorf("line %d: unexpected %q before a 'palette:' or 'grid:' section", lineNo, line)
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return s, s.validate()
}
func (s *Sprite) parsePaletteLine(line string, lineNo int) error {
eq := strings.Index(line, "=")
if eq < 0 {
return fmt.Errorf("line %d: palette entry %q must look like '<key> = <color>'", lineNo, line)
}
keyPart := []rune(strings.TrimSpace(line[:eq]))
if len(keyPart) != 1 {
return fmt.Errorf("line %d: palette key %q must be exactly one character", lineNo, strings.TrimSpace(line[:eq]))
}
key := keyPart[0]
if key == '#' || key == '=' || key == ':' {
return fmt.Errorf("line %d: %q is not allowed as a palette key", lineNo, key)
}
if _, dup := s.Palette[key]; dup {
return fmt.Errorf("line %d: palette key %q defined twice", lineNo, key)
}
val := strings.TrimSpace(line[eq+1:])
// The color is the first token; anything after it is a free-text comment.
token := val
if strings.HasPrefix(strings.ToLower(val), "rgb") {
if close := strings.Index(val, ")"); close >= 0 {
token = val[:close+1]
}
} else if i := strings.IndexAny(val, " \t"); i >= 0 {
token = val[:i]
}
c, err := ParseColor(token)
if err != nil {
return fmt.Errorf("line %d: %w", lineNo, err)
}
s.Palette[key] = c
s.Keys = append(s.Keys, key)
return nil
}
func (s *Sprite) validate() error {
if len(s.Rows) == 0 {
return fmt.Errorf("no 'grid:' section with at least one row found")
}
// '.' is transparent unless the file overrides it.
if _, ok := s.Palette['.']; !ok {
s.Palette['.'] = Transparent
}
s.H = len(s.Rows)
s.W = len(s.Rows[0])
if s.W > MaxSize || s.H > MaxSize {
return fmt.Errorf("sprite is %dx%d pixels; the maximum is %dx%d", s.W, s.H, MaxSize, MaxSize)
}
for y, row := range s.Rows {
if len(row) != s.W {
return fmt.Errorf("grid row %d is %d pixels wide, expected %d (all rows must match row 1)", y+1, len(row), s.W)
}
for x, key := range row {
if _, ok := s.Palette[key]; !ok {
return fmt.Errorf("grid row %d, column %d: %q is not defined in the palette", y+1, x+1, string(key))
}
}
}
return nil
}
// UsageCount returns how many grid pixels use each palette key.
func (s *Sprite) UsageCount() map[rune]int {
n := map[rune]int{}
for _, row := range s.Rows {
for _, k := range row {
n[k]++
}
}
return n
}

View File

@@ -0,0 +1,75 @@
package sprite
import (
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
)
// PixelGrid is anything that can be rendered pixel by pixel: a single
// Sprite or a composed Sheet.
type PixelGrid interface {
Bounds() (w, h int)
At(x, y int) color.NRGBA
}
// Image renders a PixelGrid to an NRGBA image, scaled up by the integer
// factor scale (nearest neighbour, keeps pixels crisp).
func Image(g PixelGrid, scale int) *image.NRGBA {
if scale < 1 {
scale = 1
}
w, h := g.Bounds()
img := image.NewNRGBA(image.Rect(0, 0, w*scale, h*scale))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := g.At(x, y)
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
img.SetNRGBA(x*scale+dx, y*scale+dy, c)
}
}
}
}
return img
}
// WritePNG encodes g as PNG with transparency preserved.
func WritePNG(w io.Writer, g PixelGrid, scale int) error {
return png.Encode(w, Image(g, scale))
}
// WriteJPG encodes g as JPEG. JPEG has no alpha channel, so transparent
// pixels are composited over bg first.
func WriteJPG(w io.Writer, g PixelGrid, scale int, bg color.NRGBA, quality int) error {
if quality < 1 || quality > 100 {
return fmt.Errorf("jpg quality %d out of range 1-100", quality)
}
src := Image(g, scale)
b := src.Bounds()
flat := image.NewRGBA(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
flat.Set(x, y, blendOver(src.NRGBAAt(x, y), bg))
}
}
return jpeg.Encode(w, flat, &jpeg.Options{Quality: quality})
}
// blendOver composites src over an opaque background color.
func blendOver(src, bg color.NRGBA) color.NRGBA {
if src.A == 255 {
return src
}
a := uint32(src.A)
inv := 255 - a
return color.NRGBA{
R: uint8((uint32(src.R)*a + uint32(bg.R)*inv) / 255),
G: uint8((uint32(src.G)*a + uint32(bg.G)*inv) / 255),
B: uint8((uint32(src.B)*a + uint32(bg.B)*inv) / 255),
A: 255,
}
}

View File

@@ -0,0 +1,58 @@
package sprite
import (
"fmt"
"image/color"
)
// Sheet lays out several equally sized sprites in a row-major grid:
// index 0 is top-left, then left-to-right, then next row. A 1D strip is
// simply a sheet with one row (or one column).
type Sheet struct {
Sprites []*Sprite
Cols, Rows int
CellW, CellH int
}
// NewSheet composes sprites into a sheet with the given number of columns.
// cols <= 0 puts everything in a single row. Every sprite must have the
// same dimensions so the sheet can be indexed cell by cell.
func NewSheet(sprites []*Sprite, cols int) (*Sheet, error) {
if len(sprites) == 0 {
return nil, fmt.Errorf("a sheet needs at least one sprite")
}
w, h := sprites[0].Bounds()
for _, sp := range sprites[1:] {
sw, sh := sp.Bounds()
if sw != w || sh != h {
return nil, fmt.Errorf("sprite %q is %dx%d but %q is %dx%d — all sprites in a sheet must be the same size",
sp.Name, sw, sh, sprites[0].Name, w, h)
}
}
if cols <= 0 || cols > len(sprites) {
cols = len(sprites)
}
rows := (len(sprites) + cols - 1) / cols
return &Sheet{Sprites: sprites, Cols: cols, Rows: rows, CellW: w, CellH: h}, nil
}
// Bounds returns the total sheet size in pixels.
func (sh *Sheet) Bounds() (w, h int) { return sh.Cols * sh.CellW, sh.Rows * sh.CellH }
// At returns the pixel at (x, y). Cells past the last sprite (when the
// sprite count doesn't fill the grid) are transparent.
func (sh *Sheet) At(x, y int) color.NRGBA {
col, row := x/sh.CellW, y/sh.CellH
idx := row*sh.Cols + col
if idx >= len(sh.Sprites) {
return Transparent
}
return sh.Sprites[idx].At(x%sh.CellW, y%sh.CellH)
}
// FileBase appends the layout to an output name so the file itself
// documents cell size and orientation: <base>_<cellW>x<cellH>_<cols>x<rows>
// e.g. walk_16x16_4x2 = 16x16 cells, 4 columns, 2 rows, read row by row.
func (sh *Sheet) FileBase(base string) string {
return fmt.Sprintf("%s_%dx%d_%dx%d", base, sh.CellW, sh.CellH, sh.Cols, sh.Rows)
}

View File

@@ -0,0 +1,207 @@
package sprite
import (
"bytes"
"encoding/xml"
"image/color"
"strings"
"testing"
)
const coin = `
# a tiny coin
sprite: coin
palette:
k = #000000
y = gold
Y = #FFF3A0 highlight
grid:
.kk.
kyYk
kyyk
.kk.
`
func parseOK(t *testing.T, src string) *Sprite {
t.Helper()
s, err := Parse(strings.NewReader(src))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
return s
}
func TestParseBasics(t *testing.T) {
s := parseOK(t, coin)
if s.Name != "coin" {
t.Errorf("name = %q, want coin", s.Name)
}
if s.W != 4 || s.H != 4 {
t.Errorf("size = %dx%d, want 4x4", s.W, s.H)
}
if got := s.At(0, 0); got.A != 0 {
t.Errorf("(0,0) should be transparent via the implicit '.', got %v", got)
}
if got := s.At(1, 1); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("(1,1) = %v, want gold", got)
}
if got := s.At(2, 1); got != (color.NRGBA{255, 243, 160, 255}) {
t.Errorf("(2,1) = %v, want #FFF3A0", got)
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"ragged rows": "palette:\n a = red\ngrid:\naa\naaa\n",
"unknown key": "palette:\n a = red\ngrid:\nab\naa\n",
"bad color": "palette:\n a = notacolor\ngrid:\na\n",
"no grid": "palette:\n a = red\n",
"dup key": "palette:\n a = red\n a = blue\ngrid:\na\n",
"multirune key": "palette:\n ab = red\ngrid:\na\n",
"colon key": "palette:\n : = red\ngrid:\n.\n",
}
for name, src := range cases {
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestMaxSize(t *testing.T) {
row := strings.Repeat("a", 257)
src := "palette:\n a = red\ngrid:\n" + row + "\n"
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Error("257 px wide sprite should be rejected")
}
ok := "palette:\n a = red\ngrid:\n" + strings.Repeat(strings.Repeat("a", 256)+"\n", 256)
s := parseOK(t, ok)
if s.W != 256 || s.H != 256 {
t.Errorf("size = %dx%d, want 256x256", s.W, s.H)
}
}
func TestParseColorForms(t *testing.T) {
cases := map[string]color.NRGBA{
"none": {0, 0, 0, 0},
"-": {0, 0, 0, 0},
"#F00": {255, 0, 0, 255},
"#F00A": {255, 0, 0, 170},
"#00FF00": {0, 255, 0, 255},
"#00FF0080": {0, 255, 0, 128},
"rgb(1,2,3)": {1, 2, 3, 255},
"rgba(1,2,3,64)": {1, 2, 3, 64},
"rgba(1, 2, 3, .5)": {1, 2, 3, 128},
"RebeccaPurple": {102, 51, 153, 255},
}
for in, want := range cases {
got, err := ParseColor(in)
if err != nil {
t.Errorf("ParseColor(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("ParseColor(%q) = %v, want %v", in, got, want)
}
}
for _, bad := range []string{"", "#12345", "rgb(300,0,0)", "purpleish"} {
if _, err := ParseColor(bad); err == nil {
t.Errorf("ParseColor(%q): expected error", bad)
}
}
}
func TestImageAndScale(t *testing.T) {
s := parseOK(t, coin)
img := Image(s, 3)
if b := img.Bounds(); b.Dx() != 12 || b.Dy() != 12 {
t.Fatalf("scaled bounds = %v, want 12x12", b)
}
// pixel (1,1) is gold -> block at (3..5, 3..5)
if got := img.NRGBAAt(4, 4); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("scaled gold pixel = %v", got)
}
if got := img.NRGBAAt(0, 0); got.A != 0 {
t.Errorf("corner should stay transparent, got %v", got)
}
}
func TestSVGOutput(t *testing.T) {
s := parseOK(t, coin)
var buf bytes.Buffer
if err := WriteSVG(&buf, s, 10); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, `viewBox="0 0 4 4"`) || !strings.Contains(out, `width="40"`) {
t.Errorf("svg header wrong:\n%s", out)
}
// row 2 (y=1) has runs k, yY?, no: k y Y k -> y and Y differ, so no merge.
// row 3 (y=2) k yy k -> the two golds merge into one rect of width 2.
if !strings.Contains(out, `<rect x="1" y="2" width="2" height="1" fill="#ffd700"/>`) {
t.Errorf("expected RLE-merged gold rect:\n%s", out)
}
var doc struct{ XMLName xml.Name }
if err := xml.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Errorf("svg is not valid XML: %v", err)
}
}
func TestSheetLayoutAndNaming(t *testing.T) {
a := parseOK(t, coin)
b := parseOK(t, coin)
c := parseOK(t, coin)
sh, err := NewSheet([]*Sprite{a, b, c}, 2)
if err != nil {
t.Fatal(err)
}
if sh.Cols != 2 || sh.Rows != 2 {
t.Errorf("layout = %dx%d, want 2x2", sh.Cols, sh.Rows)
}
if w, h := sh.Bounds(); w != 8 || h != 8 {
t.Errorf("bounds = %dx%d, want 8x8", w, h)
}
if got := sh.FileBase("walk"); got != "walk_4x4_2x2" {
t.Errorf("FileBase = %q, want walk_4x4_2x2", got)
}
// cell (1,1) in frame 2 (top-right) is gold
if got := sh.At(5, 1); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("sheet pixel in frame 2 = %v, want gold", got)
}
// 4th cell (bottom-right) has no sprite -> transparent
if got := sh.At(7, 7); got.A != 0 {
t.Errorf("empty cell should be transparent, got %v", got)
}
}
func TestSheetSizeMismatch(t *testing.T) {
a := parseOK(t, coin)
b := parseOK(t, "palette:\n a = red\ngrid:\naa\n")
if _, err := NewSheet([]*Sprite{a, b}, 0); err == nil {
t.Error("mismatched sprite sizes should be rejected")
}
}
func TestSheetDefaultSingleRow(t *testing.T) {
a := parseOK(t, coin)
sh, err := NewSheet([]*Sprite{a, a, a}, 0)
if err != nil {
t.Fatal(err)
}
if sh.Cols != 3 || sh.Rows != 1 {
t.Errorf("default layout = %dx%d, want 3x1", sh.Cols, sh.Rows)
}
}
func TestJPGComposite(t *testing.T) {
s := parseOK(t, coin)
var buf bytes.Buffer
if err := WriteJPG(&buf, s, 1, color.NRGBA{255, 255, 255, 255}, 90); err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Error("empty jpg output")
}
if err := WriteJPG(&buf, s, 1, Transparent, 150); err == nil {
t.Error("quality 150 should be rejected")
}
}

View File

@@ -0,0 +1,46 @@
package sprite
import (
"fmt"
"io"
)
// WriteSVG encodes g as an SVG where each horizontal run of same-colored
// pixels becomes one <rect>. shape-rendering="crispEdges" keeps the pixel
// look at any zoom. scale only affects the document width/height; the
// viewBox stays in pixel units.
func WriteSVG(w io.Writer, g PixelGrid, scale int) error {
if scale < 1 {
scale = 1
}
gw, gh := g.Bounds()
_, err := fmt.Fprintf(w,
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" shape-rendering="crispEdges">`+"\n",
gw*scale, gh*scale, gw, gh)
if err != nil {
return err
}
for y := 0; y < gh; y++ {
for x := 0; x < gw; {
c := g.At(x, y)
run := 1
for x+run < gw && g.At(x+run, y) == c {
run++
}
if c.A > 0 {
opacity := ""
if c.A < 255 {
opacity = fmt.Sprintf(` fill-opacity="%.3f"`, float64(c.A)/255)
}
_, err = fmt.Fprintf(w, `<rect x="%d" y="%d" width="%d" height="1" fill="#%02x%02x%02x"%s/>`+"\n",
x, y, run, c.R, c.G, c.B, opacity)
if err != nil {
return err
}
}
x += run
}
}
_, err = io.WriteString(w, "</svg>\n")
return err
}