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:
406
mesh-tool/main.go
Normal file
406
mesh-tool/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user