5 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
22 changed files with 35053 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

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