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