- 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
183 lines
4.7 KiB
Go
183 lines
4.7 KiB
Go
package mesh
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ReadSTL reads binary or ASCII STL (auto-detected). STL stores loose
|
|
// triangles, so identical vertices are welded back together to recover
|
|
// connectivity (needed for watertight checks and sane OBJ export).
|
|
func ReadSTL(r io.Reader) (*Scene, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) >= 84 {
|
|
n := binary.LittleEndian.Uint32(data[80:84])
|
|
if int(84+50*n) == len(data) {
|
|
return readSTLBinary(data)
|
|
}
|
|
}
|
|
if bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte("solid")) {
|
|
return readSTLAscii(data)
|
|
}
|
|
return nil, fmt.Errorf("not a valid STL file (neither binary layout nor 'solid ...' text)")
|
|
}
|
|
|
|
type welder struct {
|
|
mesh *Mesh
|
|
index map[Vec3]int
|
|
}
|
|
|
|
func newWelder(name string) *welder {
|
|
return &welder{mesh: &Mesh{Name: name}, index: map[Vec3]int{}}
|
|
}
|
|
|
|
func (w *welder) add(a, b, c Vec3) {
|
|
var t Triangle
|
|
for i, v := range [3]Vec3{a, b, c} {
|
|
idx, ok := w.index[v]
|
|
if !ok {
|
|
idx = len(w.mesh.Verts)
|
|
w.mesh.Verts = append(w.mesh.Verts, v)
|
|
w.index[v] = idx
|
|
}
|
|
t[i] = idx
|
|
}
|
|
if t[0] == t[1] || t[1] == t[2] || t[2] == t[0] {
|
|
return // degenerate
|
|
}
|
|
w.mesh.Tris = append(w.mesh.Tris, t)
|
|
}
|
|
|
|
func readSTLBinary(data []byte) (*Scene, error) {
|
|
n := int(binary.LittleEndian.Uint32(data[80:84]))
|
|
w := newWelder("stl")
|
|
off := 84
|
|
f32 := func(o int) float64 {
|
|
return float64(math.Float32frombits(binary.LittleEndian.Uint32(data[o : o+4])))
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
// 12 bytes normal (ignored), 3 * 12 bytes vertices, 2 bytes attrs
|
|
var v [3]Vec3
|
|
for k := 0; k < 3; k++ {
|
|
base := off + 12 + k*12
|
|
v[k] = Vec3{f32(base), f32(base + 4), f32(base + 8)}
|
|
}
|
|
w.add(v[0], v[1], v[2])
|
|
off += 50
|
|
}
|
|
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
|
|
}
|
|
|
|
func readSTLAscii(data []byte) (*Scene, error) {
|
|
name := "stl"
|
|
w := newWelder(name)
|
|
var cur []Vec3
|
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
|
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
lineNo := 0
|
|
for sc.Scan() {
|
|
lineNo++
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
switch fields[0] {
|
|
case "solid":
|
|
if len(fields) > 1 {
|
|
w.mesh.Name = fields[1]
|
|
}
|
|
case "vertex":
|
|
if len(fields) < 4 {
|
|
return nil, fmt.Errorf("stl line %d: vertex needs x y z", lineNo)
|
|
}
|
|
var v Vec3
|
|
var err error
|
|
if v.X, err = strconv.ParseFloat(fields[1], 64); err == nil {
|
|
if v.Y, err = strconv.ParseFloat(fields[2], 64); err == nil {
|
|
v.Z, err = strconv.ParseFloat(fields[3], 64)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stl line %d: bad vertex: %v", lineNo, err)
|
|
}
|
|
cur = append(cur, v)
|
|
case "endfacet":
|
|
if len(cur) != 3 {
|
|
return nil, fmt.Errorf("stl line %d: facet has %d vertices, want 3", lineNo, len(cur))
|
|
}
|
|
w.add(cur[0], cur[1], cur[2])
|
|
cur = cur[:0]
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(w.mesh.Tris) == 0 {
|
|
return nil, fmt.Errorf("stl contains no triangles")
|
|
}
|
|
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
|
|
}
|
|
|
|
// WriteSTLBinary writes the whole scene as one binary STL solid
|
|
// (STL has no concept of multiple named objects).
|
|
func WriteSTLBinary(w io.Writer, s *Scene) error {
|
|
m := s.Merged()
|
|
bw := bufio.NewWriter(w)
|
|
header := make([]byte, 80)
|
|
copy(header, []byte("exported by mesht (agent-tools)"))
|
|
bw.Write(header)
|
|
binary.Write(bw, binary.LittleEndian, uint32(len(m.Tris)))
|
|
buf := make([]byte, 50)
|
|
for i, t := range m.Tris {
|
|
n := m.FaceNormal(i)
|
|
le := binary.LittleEndian
|
|
le.PutUint32(buf[0:], math.Float32bits(float32(n.X)))
|
|
le.PutUint32(buf[4:], math.Float32bits(float32(n.Y)))
|
|
le.PutUint32(buf[8:], math.Float32bits(float32(n.Z)))
|
|
for k := 0; k < 3; k++ {
|
|
v := m.Verts[t[k]]
|
|
le.PutUint32(buf[12+k*12:], math.Float32bits(float32(v.X)))
|
|
le.PutUint32(buf[16+k*12:], math.Float32bits(float32(v.Y)))
|
|
le.PutUint32(buf[20+k*12:], math.Float32bits(float32(v.Z)))
|
|
}
|
|
buf[48], buf[49] = 0, 0
|
|
bw.Write(buf)
|
|
}
|
|
return bw.Flush()
|
|
}
|
|
|
|
// WriteSTLAscii writes the scene as a text STL solid.
|
|
func WriteSTLAscii(w io.Writer, s *Scene) error {
|
|
m := s.Merged()
|
|
bw := bufio.NewWriter(w)
|
|
fmt.Fprintf(bw, "solid %s\n", sanitizeToken(m.Name))
|
|
for i, t := range m.Tris {
|
|
n := m.FaceNormal(i)
|
|
fmt.Fprintf(bw, " facet normal %g %g %g\n outer loop\n", n.X, n.Y, n.Z)
|
|
for k := 0; k < 3; k++ {
|
|
v := m.Verts[t[k]]
|
|
fmt.Fprintf(bw, " vertex %g %g %g\n", v.X, v.Y, v.Z)
|
|
}
|
|
fmt.Fprintf(bw, " endloop\n endfacet\n")
|
|
}
|
|
fmt.Fprintf(bw, "endsolid %s\n", sanitizeToken(m.Name))
|
|
return bw.Flush()
|
|
}
|
|
|
|
func sanitizeToken(s string) string {
|
|
s = strings.ReplaceAll(strings.TrimSpace(s), " ", "_")
|
|
if s == "" {
|
|
return "mesh"
|
|
}
|
|
return s
|
|
}
|