- 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
407 lines
12 KiB
Go
407 lines
12 KiB
Go
// 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)
|
|
}
|