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
This commit is contained in:
203
mesh-tool/mesh/obj.go
Normal file
203
mesh-tool/mesh/obj.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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:])
|
||||
}
|
||||
Reference in New Issue
Block a user