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 }