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