- 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
185 lines
4.7 KiB
Go
185 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|