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
This commit is contained in:
2026-07-14 02:33:37 +02:00
parent 581e37acd8
commit 62065f237b
13 changed files with 1759 additions and 0 deletions

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