- 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
204 lines
5.0 KiB
Go
204 lines
5.0 KiB
Go
package mesh
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ReadOBJ parses a Wavefront OBJ file. Vertices (v), objects/groups
|
|
// (o/g) and faces (f) are honored; polygons are fan-triangulated;
|
|
// normals, texture coords and materials are ignored (they are
|
|
// recomputed or irrelevant for geometry editing).
|
|
func ReadOBJ(r io.Reader) (*Scene, error) {
|
|
var verts []Vec3
|
|
type objFaces struct {
|
|
name string
|
|
tris []Triangle // indices into the global vert list
|
|
}
|
|
objs := []*objFaces{}
|
|
current := func() *objFaces {
|
|
if len(objs) == 0 {
|
|
objs = append(objs, &objFaces{name: "default"})
|
|
}
|
|
return objs[len(objs)-1]
|
|
}
|
|
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
lineNo := 0
|
|
for sc.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
switch fields[0] {
|
|
case "v":
|
|
if len(fields) < 4 {
|
|
return nil, fmt.Errorf("obj 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("obj line %d: bad vertex: %v", lineNo, err)
|
|
}
|
|
verts = append(verts, v)
|
|
case "o", "g":
|
|
name := "unnamed"
|
|
if len(fields) > 1 {
|
|
name = strings.Join(fields[1:], " ")
|
|
}
|
|
// only open a new object if the current one has faces
|
|
if len(objs) > 0 && len(objs[len(objs)-1].tris) == 0 {
|
|
objs[len(objs)-1].name = name
|
|
} else {
|
|
objs = append(objs, &objFaces{name: name})
|
|
}
|
|
case "f":
|
|
if len(fields) < 4 {
|
|
return nil, fmt.Errorf("obj line %d: face needs at least 3 vertices", lineNo)
|
|
}
|
|
idx := make([]int, 0, len(fields)-1)
|
|
for _, f := range fields[1:] {
|
|
// "v", "v/vt", "v//vn", "v/vt/vn" — we only need v
|
|
vs := strings.SplitN(f, "/", 2)[0]
|
|
i, err := strconv.Atoi(vs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("obj line %d: bad face index %q", lineNo, f)
|
|
}
|
|
if i < 0 {
|
|
i = len(verts) + i // negative = relative to current count
|
|
} else {
|
|
i-- // obj is 1-based
|
|
}
|
|
if i < 0 || i >= len(verts) {
|
|
return nil, fmt.Errorf("obj line %d: face index %q out of range (have %d vertices)", lineNo, f, len(verts))
|
|
}
|
|
idx = append(idx, i)
|
|
}
|
|
o := current()
|
|
for k := 1; k+1 < len(idx); k++ { // fan triangulation
|
|
o.tris = append(o.tris, Triangle{idx[0], idx[k], idx[k+1]})
|
|
}
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Compact the global vertex list into per-mesh local lists.
|
|
scene := &Scene{}
|
|
for _, o := range objs {
|
|
if len(o.tris) == 0 {
|
|
continue
|
|
}
|
|
m := &Mesh{Name: o.name}
|
|
remap := map[int]int{}
|
|
for _, t := range o.tris {
|
|
var lt Triangle
|
|
for k, gi := range t {
|
|
li, ok := remap[gi]
|
|
if !ok {
|
|
li = len(m.Verts)
|
|
m.Verts = append(m.Verts, verts[gi])
|
|
remap[gi] = li
|
|
}
|
|
lt[k] = li
|
|
}
|
|
m.Tris = append(m.Tris, lt)
|
|
}
|
|
scene.Meshes = append(scene.Meshes, m)
|
|
}
|
|
if len(scene.Meshes) == 0 {
|
|
return nil, fmt.Errorf("obj contains no faces")
|
|
}
|
|
return scene, nil
|
|
}
|
|
|
|
// WriteOBJ writes the scene as OBJ, one "o" object per mesh.
|
|
func WriteOBJ(w io.Writer, s *Scene) error {
|
|
bw := bufio.NewWriter(w)
|
|
fmt.Fprintln(bw, "# exported by mesht (agent-tools)")
|
|
offset := 1 // obj indices are global and 1-based
|
|
for _, m := range s.Meshes {
|
|
fmt.Fprintf(bw, "o %s\n", m.Name)
|
|
for _, v := range m.Verts {
|
|
fmt.Fprintf(bw, "v %g %g %g\n", v.X, v.Y, v.Z)
|
|
}
|
|
for _, t := range m.Tris {
|
|
fmt.Fprintf(bw, "f %d %d %d\n", t[0]+offset, t[1]+offset, t[2]+offset)
|
|
}
|
|
offset += len(m.Verts)
|
|
}
|
|
return bw.Flush()
|
|
}
|
|
|
|
// ReadFile loads a scene, picking the format from the file extension
|
|
// (.obj, .stl).
|
|
func ReadFile(path string) (*Scene, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
switch ext(path) {
|
|
case "obj":
|
|
s, err := ReadOBJ(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return s, nil
|
|
case "stl":
|
|
s, err := ReadSTL(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return s, nil
|
|
}
|
|
return nil, fmt.Errorf("%s: unsupported format (use .obj or .stl)", path)
|
|
}
|
|
|
|
// WriteFile saves a scene, picking the format from the file extension.
|
|
// asciiSTL selects text STL instead of the default binary.
|
|
func WriteFile(path string, s *Scene, asciiSTL bool) error {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
switch ext(path) {
|
|
case "obj":
|
|
err = WriteOBJ(f, s)
|
|
case "stl":
|
|
if asciiSTL {
|
|
err = WriteSTLAscii(f, s)
|
|
} else {
|
|
err = WriteSTLBinary(f, s)
|
|
}
|
|
default:
|
|
err = fmt.Errorf("unsupported output format (use .obj or .stl)")
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
func ext(path string) string {
|
|
i := strings.LastIndex(path, ".")
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(path[i+1:])
|
|
}
|