- 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
130 lines
2.7 KiB
Go
130 lines
2.7 KiB
Go
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
|
|
}
|