// 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 stats: size, volume, watertightness mesht view [flags] ASCII render from several angles mesht create [flags] box|sphere|cylinder|cone|plane|torus mesht transform [flags] scale/rotate/translate/mirror/center/fit mesht merge ... -o combine several files into one mesht convert -o obj <-> stl mesht version View flags: --views front,side,top,iso,back which views to draw (default front,side,top) --width characters per view (default 64) --object only draw one object from the file Create flags (always with -o ): 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 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 only transform the named object (obj files) -o 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 ") } 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 ") } 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 ") } 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) }