From 62065f237be24cbb957a5dd835fcc55aaa4b14eb Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 14 Jul 2026 02:33:37 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh --- mesh-tool/.gitignore | 2 + mesh-tool/README.md | 101 +++++++++ mesh-tool/go.mod | 3 + mesh-tool/main.go | 406 +++++++++++++++++++++++++++++++++++ mesh-tool/mesh/ascii.go | 169 +++++++++++++++ mesh-tool/mesh/measure.go | 59 +++++ mesh-tool/mesh/mesh.go | 129 +++++++++++ mesh-tool/mesh/mesh_test.go | 184 ++++++++++++++++ mesh-tool/mesh/obj.go | 203 ++++++++++++++++++ mesh-tool/mesh/primitives.go | 179 +++++++++++++++ mesh-tool/mesh/stl.go | 182 ++++++++++++++++ mesh-tool/mesh/transform.go | 20 ++ mesh-tool/mesh/vec.go | 122 +++++++++++ 13 files changed, 1759 insertions(+) create mode 100644 mesh-tool/.gitignore create mode 100644 mesh-tool/README.md create mode 100644 mesh-tool/go.mod create mode 100644 mesh-tool/main.go create mode 100644 mesh-tool/mesh/ascii.go create mode 100644 mesh-tool/mesh/measure.go create mode 100644 mesh-tool/mesh/mesh.go create mode 100644 mesh-tool/mesh/mesh_test.go create mode 100644 mesh-tool/mesh/obj.go create mode 100644 mesh-tool/mesh/primitives.go create mode 100644 mesh-tool/mesh/stl.go create mode 100644 mesh-tool/mesh/transform.go create mode 100644 mesh-tool/mesh/vec.go diff --git a/mesh-tool/.gitignore b/mesh-tool/.gitignore new file mode 100644 index 0000000..72c98da --- /dev/null +++ b/mesh-tool/.gitignore @@ -0,0 +1,2 @@ +build/ +examples/downloads/ diff --git a/mesh-tool/README.md b/mesh-tool/README.md new file mode 100644 index 0000000..18d3ca1 --- /dev/null +++ b/mesh-tool/README.md @@ -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 +``` diff --git a/mesh-tool/go.mod b/mesh-tool/go.mod new file mode 100644 index 0000000..3e92f94 --- /dev/null +++ b/mesh-tool/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/mesh-tool + +go 1.24 diff --git a/mesh-tool/main.go b/mesh-tool/main.go new file mode 100644 index 0000000..ffcd117 --- /dev/null +++ b/mesh-tool/main.go @@ -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 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) +} diff --git a/mesh-tool/mesh/ascii.go b/mesh-tool/mesh/ascii.go new file mode 100644 index 0000000..a930bc0 --- /dev/null +++ b/mesh-tool/mesh/ascii.go @@ -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 +} diff --git a/mesh-tool/mesh/measure.go b/mesh-tool/mesh/measure.go new file mode 100644 index 0000000..6e8bbfc --- /dev/null +++ b/mesh-tool/mesh/measure.go @@ -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 +} diff --git a/mesh-tool/mesh/mesh.go b/mesh-tool/mesh/mesh.go new file mode 100644 index 0000000..ad47184 --- /dev/null +++ b/mesh-tool/mesh/mesh.go @@ -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 +} diff --git a/mesh-tool/mesh/mesh_test.go b/mesh-tool/mesh/mesh_test.go new file mode 100644 index 0000000..feac47a --- /dev/null +++ b/mesh-tool/mesh/mesh_test.go @@ -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) + } +} diff --git a/mesh-tool/mesh/obj.go b/mesh-tool/mesh/obj.go new file mode 100644 index 0000000..be29b48 --- /dev/null +++ b/mesh-tool/mesh/obj.go @@ -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:]) +} diff --git a/mesh-tool/mesh/primitives.go b/mesh-tool/mesh/primitives.go new file mode 100644 index 0000000..558492e --- /dev/null +++ b/mesh-tool/mesh/primitives.go @@ -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) +} diff --git a/mesh-tool/mesh/stl.go b/mesh-tool/mesh/stl.go new file mode 100644 index 0000000..5e2e649 --- /dev/null +++ b/mesh-tool/mesh/stl.go @@ -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 +} diff --git a/mesh-tool/mesh/transform.go b/mesh-tool/mesh/transform.go new file mode 100644 index 0000000..a2a91c2 --- /dev/null +++ b/mesh-tool/mesh/transform.go @@ -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) + } +} diff --git a/mesh-tool/mesh/vec.go b/mesh-tool/mesh/vec.go new file mode 100644 index 0000000..17e60b0 --- /dev/null +++ b/mesh-tool/mesh/vec.go @@ -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, + } +}