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

184
mesh-tool/mesh/mesh_test.go Normal file
View File

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