- 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
170 lines
4.5 KiB
Go
170 lines
4.5 KiB
Go
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
|
|
}
|