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:
2026-07-14 02:33:37 +02:00
parent 581e37acd8
commit 62065f237b
13 changed files with 1759 additions and 0 deletions

169
mesh-tool/mesh/ascii.go Normal file
View File

@@ -0,0 +1,169 @@
package mesh
import (
"fmt"
"math"
"strings"
)
// View is an orthographic camera basis: Right/Up span the screen plane,
// Toward points from the scene toward the viewer (bigger depth = closer).
type View struct {
Name string
Axes string // human-readable axis legend
Right, Up, Toward Vec3
}
var Views = map[string]View{
"front": {"front", "X→right Y↑up (seen from +Z)", Vec3{1, 0, 0}, Vec3{0, 1, 0}, Vec3{0, 0, 1}},
"back": {"back", "-X→right Y↑up (seen from -Z)", Vec3{-1, 0, 0}, Vec3{0, 1, 0}, Vec3{0, 0, -1}},
"side": {"side", "-Z→right Y↑up (seen from +X)", Vec3{0, 0, -1}, Vec3{0, 1, 0}, Vec3{1, 0, 0}},
"top": {"top", "X→right Z↓down-screen (seen from above, +Y)", Vec3{1, 0, 0}, Vec3{0, 0, -1}, Vec3{0, 1, 0}},
"iso": {"iso", "isometric from (+X +Y +Z)",
Vec3{1, 0, -1}.Norm(), Vec3{-1, 2, -1}.Norm(), Vec3{1, 1, 1}.Norm()},
}
// ViewOrder is the canonical ordering for multi-view output.
var ViewOrder = []string{"front", "side", "top", "iso", "back"}
const shadeRamp = " .:-=+*#%@"
// charAspect compensates terminal cells being ~2x taller than wide.
const charAspect = 0.5
// RenderASCII draws the mesh from the given view into a text block of
// the given character width. Triangles are z-buffer rasterized and
// shaded by how much each face points toward the light (over the
// viewer's shoulder), so curvature and depth read as brightness.
func RenderASCII(m *Mesh, v View, width int) string {
if width < 8 {
width = 8
}
if len(m.Tris) == 0 {
return "(empty mesh)\n"
}
// project all vertices into view space
type pv struct{ x, y, z float64 }
pts := make([]pv, len(m.Verts))
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
for i, w := range m.Verts {
p := pv{w.Dot(v.Right), w.Dot(v.Up), w.Dot(v.Toward)}
pts[i] = p
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
}
spanX, spanY := maxX-minX, maxY-minY
if spanX == 0 {
spanX = 1e-9
}
if spanY == 0 {
spanY = 1e-9
}
height := int(float64(width) * (spanY / spanX) * charAspect)
if height < 1 {
height = 1
}
if height > 4*width {
height = 4 * width
}
sx := float64(width-1) / spanX
sy := float64(height-1) / spanY
depth := make([]float64, width*height)
for i := range depth {
depth[i] = math.Inf(-1)
}
shade := make([]float64, width*height)
for i := range shade {
shade[i] = -1
}
light := v.Toward.Mul(0.8).Add(v.Up.Mul(0.5)).Add(v.Right.Mul(0.3)).Norm()
for ti, t := range m.Tris {
n := m.FaceNormal(ti)
// abs: downloaded models often have mixed winding; treat both
// sides as lit so the silhouette never goes black
lum := 0.15 + 0.85*math.Abs(n.Dot(light))
a, b, c := pts[t[0]], pts[t[1]], pts[t[2]]
ax, ay := (a.x-minX)*sx, (maxY-a.y)*sy
bx, by := (b.x-minX)*sx, (maxY-b.y)*sy
cx, cy := (c.x-minX)*sx, (maxY-c.y)*sy
x0 := int(math.Floor(math.Min(ax, math.Min(bx, cx))))
x1 := int(math.Ceil(math.Max(ax, math.Max(bx, cx))))
y0 := int(math.Floor(math.Min(ay, math.Min(by, cy))))
y1 := int(math.Ceil(math.Max(ay, math.Max(by, cy))))
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if x1 >= width {
x1 = width - 1
}
if y1 >= height {
y1 = height - 1
}
area := (bx-ax)*(cy-ay) - (by-ay)*(cx-ax)
if area == 0 {
continue
}
for py := y0; py <= y1; py++ {
for px := x0; px <= x1; px++ {
fx, fy := float64(px), float64(py)
w0 := (bx-ax)*(fy-ay) - (by-ay)*(fx-ax)
w1 := (cx-bx)*(fy-by) - (cy-by)*(fx-bx)
w2 := (ax-cx)*(fy-cy) - (ay-cy)*(fx-cx)
if !sameSide(w0, w1, w2, area) {
continue
}
// barycentric depth: w2 tracks b, w0 tracks c
l1 := w2 / area
l2 := w0 / area
l0 := 1 - l1 - l2
z := l0*a.z + l1*b.z + l2*c.z
idx := py*width + px
if z > depth[idx] {
depth[idx] = z
shade[idx] = lum
}
}
}
}
var sb strings.Builder
mn, mx := m.BBox()
size := mx.Sub(mn)
fmt.Fprintf(&sb, "%s view — %s\nmodel %.3g x %.3g x %.3g (XYZ)\n",
v.Name, v.Axes, size.X, size.Y, size.Z)
ramp := []rune(shadeRamp)
for py := 0; py < height; py++ {
for px := 0; px < width; px++ {
s := shade[py*width+px]
if s < 0 {
sb.WriteByte(' ')
continue
}
i := int(s * float64(len(ramp)-1))
if i >= len(ramp) {
i = len(ramp) - 1
}
sb.WriteRune(ramp[i])
}
sb.WriteByte('\n')
}
return sb.String()
}
func sameSide(w0, w1, w2, area float64) bool {
if area > 0 {
return w0 >= 0 && w1 >= 0 && w2 >= 0
}
return w0 <= 0 && w1 <= 0 && w2 <= 0
}