Files
claude 62065f237b 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
2026-07-14 02:38:29 +02:00

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
}