Compare commits
1 Commits
dev/mesh-t
...
dev/pixel-
| Author | SHA1 | Date | |
|---|---|---|---|
| 23135a96d1 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
build/
|
||||
*.exe
|
||||
mesh-tool/examples/downloads/
|
||||
2
mesh-tool/.gitignore
vendored
2
mesh-tool/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
build/
|
||||
examples/downloads/
|
||||
@@ -1,101 +0,0 @@
|
||||
# mesh-tool (`mesht`)
|
||||
|
||||
Create, inspect and edit **3D models** from the command line — built so an
|
||||
agent can *see* a model (ASCII multi-view rendering + measurements),
|
||||
reason about it, and edit it step by step. Written in Go, zero
|
||||
dependencies, single static binary.
|
||||
|
||||
## Formats
|
||||
|
||||
| Format | Read | Write | Notes |
|
||||
|--------|------|-------|-------|
|
||||
| OBJ | ✔ | ✔ | multiple named objects, quads/ngons triangulated |
|
||||
| STL | ✔ | ✔ | binary + ASCII, auto-detected; vertices re-welded on load |
|
||||
|
||||
OBJ and STL cover the vast majority of simple editing/printing/game
|
||||
pipelines and are plain enough to survive round-trips. glTF/GLB is out of
|
||||
scope for now (a full JSON+buffer+material model; use Blender for that).
|
||||
|
||||
Normals, UVs and materials are ignored on read — this tool edits
|
||||
*geometry*. Viewers recompute normals; STL export writes correct face
|
||||
normals from the winding.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Arch/Garuda: sudo pacman -S go
|
||||
cd mesh-tool
|
||||
go build -o build/mesht . # or the VS Code task "build mesh-tool"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
mesht info model.obj # verts/tris, bbox, size, area, volume, watertight?
|
||||
mesht view model.obj # ASCII render: front, side, top (default)
|
||||
mesht view model.obj --views iso --width 100 --object wheel
|
||||
|
||||
# creation — box|sphere|cylinder|cone|plane|torus
|
||||
mesht create box --size 2,1,1 --name crate -o crate.obj
|
||||
mesht create sphere --radius 1 --segments 32 --rings 16 -o ball.stl
|
||||
|
||||
# editing (applied in the listed order)
|
||||
mesht transform m.obj --center # bbox center -> origin
|
||||
mesht transform m.obj --mirror x # winding auto-corrected
|
||||
mesht transform m.obj --scale 2 # or --scale 1,2,1
|
||||
mesht transform m.obj --rotate 0,45,0 # degrees, X then Y then Z
|
||||
mesht transform m.obj --translate 0,2,0
|
||||
mesht transform m.obj --fit 10 # largest dimension -> 10 units
|
||||
mesht transform m.obj --object wheel --scale 1.2 -o out.obj # edit one part
|
||||
|
||||
mesht merge body.obj wheels.obj -o car.obj # keeps objects, renames dups
|
||||
mesht merge a.stl b.stl --flatten -o one.obj # single combined object
|
||||
mesht convert model.obj -o model.stl # --ascii for text STL
|
||||
```
|
||||
|
||||
`-o` picks the output format from the extension. `transform` overwrites
|
||||
the input when `-o` is omitted.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Axes:** right-handed, **+Y up**, +X right, +Z toward the "front" viewer.
|
||||
- Primitives are centered on the origin.
|
||||
- Mirroring / negative scaling automatically flips triangle winding so
|
||||
surfaces keep facing outward.
|
||||
- `info` reports **volume only for watertight meshes** — otherwise it
|
||||
tells you how many boundary/non-manifold edges the mesh has.
|
||||
|
||||
## How an agent should edit a model
|
||||
|
||||
1. `mesht info m.obj` — learn size, orientation and object names.
|
||||
2. `mesht view m.obj` — see the shape (front/side/top; add `iso` for depth).
|
||||
3. Apply **one small transform**, write to a new file.
|
||||
4. `mesht view` again and compare — verify before continuing.
|
||||
|
||||
The ASCII views are z-buffered orthographic renders; brightness = how
|
||||
much the surface faces the light over the viewer's shoulder, so shape
|
||||
and curvature read directly from the text. Each view header repeats the
|
||||
axis legend and model dimensions.
|
||||
|
||||
Example (a 32-segment sphere, front view, width 48):
|
||||
|
||||
```
|
||||
front view — X→right Y↑up (seen from +Z)
|
||||
model 2 x 2 x 2 (XYZ)
|
||||
==++**####%%%%%%%%%%%%###*
|
||||
-==++**######%%%%%%%%%%%%%%####*
|
||||
:-=+++**#######%%%%%%%%%%%%%%%%%%#**
|
||||
-==++***#######%%%%%%%%%%%%%%%%%%%%##*
|
||||
.:--+++***########%%%%%%%%%%%%%%%%%%%%%%##*+
|
||||
```
|
||||
|
||||
## Viewing the results as a human
|
||||
|
||||
Any of these (Arch/Garuda):
|
||||
|
||||
```bash
|
||||
sudo pacman -S f3d # fast minimal viewer: f3d model.obj
|
||||
sudo pacman -S blender # full editor
|
||||
flatpak install org.prusa3d.PrusaSlicer # if you also want to print
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
module gitea.brasse-pc.eu/brasse/agent-tools/mesh-tool
|
||||
|
||||
go 1.24
|
||||
@@ -1,406 +0,0 @@
|
||||
// mesht creates, inspects and edits 3D models (OBJ and STL) from the
|
||||
// command line, with ASCII multi-view rendering so agents can "see"
|
||||
// a model before and after editing it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/agent-tools/mesh-tool/mesh"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const usage = `mesht - 3D model tool for agents (OBJ + STL)
|
||||
|
||||
Usage:
|
||||
mesht info <file> stats: size, volume, watertightness
|
||||
mesht view <file> [flags] ASCII render from several angles
|
||||
mesht create <primitive> [flags] box|sphere|cylinder|cone|plane|torus
|
||||
mesht transform <file> [flags] scale/rotate/translate/mirror/center/fit
|
||||
mesht merge <a> <b> ... -o <out> combine several files into one
|
||||
mesht convert <in> -o <out> obj <-> stl
|
||||
mesht version
|
||||
|
||||
View flags:
|
||||
--views front,side,top,iso,back which views to draw (default front,side,top)
|
||||
--width <n> characters per view (default 64)
|
||||
--object <name> only draw one object from the file
|
||||
|
||||
Create flags (always with -o <out.obj|out.stl>):
|
||||
box: --size x,y,z (default 1,1,1)
|
||||
sphere: --radius r --segments n --rings n (default 1, 24, 12)
|
||||
cylinder: --radius r --height h --segments n (default 0.5, 1, 24)
|
||||
cone: --radius r --height h --segments n (default 0.5, 1, 24)
|
||||
plane: --size w,d (default 1,1)
|
||||
torus: --radius R --tube r --segments n --rings n (default 1, 0.25, 24, 12)
|
||||
--name <s> object name in the output
|
||||
|
||||
Transform flags (applied in this order):
|
||||
--center move bounding-box center to the origin
|
||||
--mirror x|y|z mirror across that axis' plane
|
||||
--scale s | x,y,z uniform or per-axis scale
|
||||
--rotate x,y,z degrees around X, then Y, then Z
|
||||
--translate x,y,z move
|
||||
--fit n uniformly scale so the largest dimension = n
|
||||
--object <name> only transform the named object (obj files)
|
||||
-o <out> output file (default: overwrite input)
|
||||
|
||||
Common:
|
||||
--ascii write text STL instead of binary
|
||||
Formats are picked from file extensions (.obj, .stl).
|
||||
|
||||
Axes: +Y is up, +X right, +Z toward the front viewer (right-handed).
|
||||
`
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Print(usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "info":
|
||||
cmdInfo(os.Args[2:])
|
||||
case "view":
|
||||
cmdView(os.Args[2:])
|
||||
case "create":
|
||||
cmdCreate(os.Args[2:])
|
||||
case "transform":
|
||||
cmdTransform(os.Args[2:])
|
||||
case "merge":
|
||||
cmdMerge(os.Args[2:])
|
||||
case "convert":
|
||||
cmdConvert(os.Args[2:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("mesht", version)
|
||||
case "help", "--help", "-h":
|
||||
fmt.Print(usage)
|
||||
default:
|
||||
die("unknown command %q — run 'mesht help'", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
// parseInterspersed lets flags appear before or after positional args.
|
||||
func parseInterspersed(fs *flag.FlagSet, args []string) {
|
||||
var flags, pos []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if len(a) > 1 && a[0] == '-' {
|
||||
flags = append(flags, a)
|
||||
name := strings.TrimLeft(a, "-")
|
||||
if !strings.Contains(name, "=") {
|
||||
f := fs.Lookup(name)
|
||||
isBool := false
|
||||
if f != nil {
|
||||
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
|
||||
isBool = true
|
||||
}
|
||||
}
|
||||
if !isBool && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pos = append(pos, a)
|
||||
}
|
||||
}
|
||||
fs.Parse(append(flags, pos...))
|
||||
}
|
||||
|
||||
func parseVec(s string, uniformOK bool) (mesh.Vec3, error) {
|
||||
parts := strings.Split(s, ",")
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
if !uniformOK {
|
||||
return mesh.Vec3{}, fmt.Errorf("%q: want x,y,z", s)
|
||||
}
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
return mesh.Vec3{X: v, Y: v, Z: v}, err
|
||||
case 3:
|
||||
var v [3]float64
|
||||
for i, p := range parts {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
||||
if err != nil {
|
||||
return mesh.Vec3{}, fmt.Errorf("%q: bad number %q", s, p)
|
||||
}
|
||||
v[i] = f
|
||||
}
|
||||
return mesh.Vec3{X: v[0], Y: v[1], Z: v[2]}, nil
|
||||
}
|
||||
return mesh.Vec3{}, fmt.Errorf("%q: want one value or x,y,z", s)
|
||||
}
|
||||
|
||||
func cmdInfo(args []string) {
|
||||
if len(args) != 1 {
|
||||
die("info takes exactly one file")
|
||||
}
|
||||
scene, err := mesh.ReadFile(args[0])
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
merged := scene.Merged()
|
||||
mn, mx := merged.BBox()
|
||||
size := mx.Sub(mn)
|
||||
center := mn.Add(size.Mul(0.5))
|
||||
fmt.Printf("file: %s\n", args[0])
|
||||
fmt.Printf("objects: %d\n", len(scene.Meshes))
|
||||
fmt.Printf("verts: %d\n", scene.TotalVerts())
|
||||
fmt.Printf("tris: %d\n", scene.TotalTris())
|
||||
fmt.Printf("bbox min: (%.4g, %.4g, %.4g)\n", mn.X, mn.Y, mn.Z)
|
||||
fmt.Printf("bbox max: (%.4g, %.4g, %.4g)\n", mx.X, mx.Y, mx.Z)
|
||||
fmt.Printf("size: %.4g x %.4g x %.4g (X Y Z)\n", size.X, size.Y, size.Z)
|
||||
fmt.Printf("center: (%.4g, %.4g, %.4g)\n", center.X, center.Y, center.Z)
|
||||
fmt.Printf("area: %.6g\n", merged.SurfaceArea())
|
||||
if b, nm := merged.EdgeStats(); b == 0 && nm == 0 {
|
||||
fmt.Printf("volume: %.6g (closed mesh)\n", merged.Volume())
|
||||
} else {
|
||||
fmt.Printf("volume: n/a (open mesh: %d boundary edges, %d non-manifold edges)\n", b, nm)
|
||||
}
|
||||
if len(scene.Meshes) > 1 {
|
||||
fmt.Println("per object:")
|
||||
for _, m := range scene.Meshes {
|
||||
closed := "open"
|
||||
if m.Closed() {
|
||||
closed = "closed"
|
||||
}
|
||||
fmt.Printf(" %-24s %7d verts %7d tris %s\n", m.Name, len(m.Verts), len(m.Tris), closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cmdView(args []string) {
|
||||
fs := flag.NewFlagSet("view", flag.ExitOnError)
|
||||
views := fs.String("views", "front,side,top", "comma-separated view list")
|
||||
width := fs.Int("width", 64, "characters per view")
|
||||
object := fs.String("object", "", "only draw this object")
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() != 1 {
|
||||
die("view takes exactly one file")
|
||||
}
|
||||
scene, err := mesh.ReadFile(fs.Arg(0))
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
m := scene.Merged()
|
||||
if *object != "" {
|
||||
if m = scene.Mesh(*object); m == nil {
|
||||
die("no object named %q in %s (use 'mesht info' to list)", *object, fs.Arg(0))
|
||||
}
|
||||
}
|
||||
for _, name := range strings.Split(*views, ",") {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
v, ok := mesh.Views[name]
|
||||
if !ok {
|
||||
die("unknown view %q (available: %s)", name, strings.Join(mesh.ViewOrder, ", "))
|
||||
}
|
||||
fmt.Println(mesh.RenderASCII(m, v, *width))
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCreate(args []string) {
|
||||
if len(args) < 1 {
|
||||
die("create needs a primitive: box, sphere, cylinder, cone, plane, torus")
|
||||
}
|
||||
prim := args[0]
|
||||
fs := flag.NewFlagSet("create", flag.ExitOnError)
|
||||
size := fs.String("size", "1,1,1", "box/plane size")
|
||||
radius := fs.Float64("radius", 0, "radius")
|
||||
tube := fs.Float64("tube", 0.25, "torus tube radius")
|
||||
height := fs.Float64("height", 1, "height")
|
||||
segments := fs.Int("segments", 24, "segments around")
|
||||
rings := fs.Int("rings", 12, "rings (sphere/torus)")
|
||||
name := fs.String("name", "", "object name")
|
||||
out := fs.String("o", "", "output file (.obj or .stl)")
|
||||
ascii := fs.Bool("ascii", false, "write text STL")
|
||||
parseInterspersed(fs, args[1:])
|
||||
if *out == "" {
|
||||
die("create needs -o <out.obj|out.stl>")
|
||||
}
|
||||
var m *mesh.Mesh
|
||||
switch prim {
|
||||
case "box":
|
||||
sz, err := parseVec(*size, true)
|
||||
if err != nil {
|
||||
die("--size: %v", err)
|
||||
}
|
||||
m = mesh.Box(sz)
|
||||
case "sphere":
|
||||
m = mesh.Sphere(defRadius(*radius, 1), *segments, *rings)
|
||||
case "cylinder":
|
||||
m = mesh.Cylinder(defRadius(*radius, 0.5), *height, *segments)
|
||||
case "cone":
|
||||
m = mesh.Cone(defRadius(*radius, 0.5), *height, *segments)
|
||||
case "plane":
|
||||
sz, err := parseVec(*size, true)
|
||||
if err != nil {
|
||||
die("--size: %v", err)
|
||||
}
|
||||
m = mesh.Plane(sz.X, sz.Z)
|
||||
case "torus":
|
||||
m = mesh.Torus(defRadius(*radius, 1), *tube, *segments, *rings)
|
||||
default:
|
||||
die("unknown primitive %q", prim)
|
||||
}
|
||||
if *name != "" {
|
||||
m.Name = *name
|
||||
}
|
||||
scene := &mesh.Scene{Meshes: []*mesh.Mesh{m}}
|
||||
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Printf("%s (%s: %d verts, %d tris)\n", *out, m.Name, len(m.Verts), len(m.Tris))
|
||||
}
|
||||
|
||||
func defRadius(r, def float64) float64 {
|
||||
if r <= 0 {
|
||||
return def
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func cmdTransform(args []string) {
|
||||
fs := flag.NewFlagSet("transform", flag.ExitOnError)
|
||||
center := fs.Bool("center", false, "move bbox center to origin")
|
||||
mirror := fs.String("mirror", "", "x|y|z")
|
||||
scale := fs.String("scale", "", "s or x,y,z")
|
||||
rotate := fs.String("rotate", "", "degrees x,y,z")
|
||||
translate := fs.String("translate", "", "x,y,z")
|
||||
fit := fs.Float64("fit", 0, "scale so the largest dimension equals this")
|
||||
object := fs.String("object", "", "only transform this object")
|
||||
out := fs.String("o", "", "output file (default: overwrite input)")
|
||||
ascii := fs.Bool("ascii", false, "write text STL")
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() != 1 {
|
||||
die("transform takes exactly one file")
|
||||
}
|
||||
in := fs.Arg(0)
|
||||
scene, err := mesh.ReadFile(in)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
targets := scene.Meshes
|
||||
if *object != "" {
|
||||
m := scene.Mesh(*object)
|
||||
if m == nil {
|
||||
die("no object named %q in %s (use 'mesht info' to list)", *object, in)
|
||||
}
|
||||
targets = []*mesh.Mesh{m}
|
||||
}
|
||||
|
||||
if *center {
|
||||
mn, mx := mesh.SceneBBox(targets)
|
||||
c := mn.Add(mx.Sub(mn).Mul(0.5))
|
||||
mesh.ApplyAll(targets, mesh.Translate(c.Mul(-1)))
|
||||
}
|
||||
if *mirror != "" {
|
||||
s := mesh.Vec3{X: 1, Y: 1, Z: 1}
|
||||
switch strings.ToLower(*mirror) {
|
||||
case "x":
|
||||
s.X = -1
|
||||
case "y":
|
||||
s.Y = -1
|
||||
case "z":
|
||||
s.Z = -1
|
||||
default:
|
||||
die("--mirror must be x, y or z")
|
||||
}
|
||||
mesh.ApplyAll(targets, mesh.ScaleXYZ(s))
|
||||
}
|
||||
if *scale != "" {
|
||||
v, err := parseVec(*scale, true)
|
||||
if err != nil {
|
||||
die("--scale: %v", err)
|
||||
}
|
||||
mesh.ApplyAll(targets, mesh.ScaleXYZ(v))
|
||||
}
|
||||
if *rotate != "" {
|
||||
v, err := parseVec(*rotate, false)
|
||||
if err != nil {
|
||||
die("--rotate: %v", err)
|
||||
}
|
||||
rot := mesh.RotateZ(v.Z).Mul(mesh.RotateY(v.Y)).Mul(mesh.RotateX(v.X))
|
||||
mesh.ApplyAll(targets, rot)
|
||||
}
|
||||
if *translate != "" {
|
||||
v, err := parseVec(*translate, false)
|
||||
if err != nil {
|
||||
die("--translate: %v", err)
|
||||
}
|
||||
mesh.ApplyAll(targets, mesh.Translate(v))
|
||||
}
|
||||
if *fit > 0 {
|
||||
mn, mx := mesh.SceneBBox(targets)
|
||||
size := mx.Sub(mn)
|
||||
longest := math.Max(size.X, math.Max(size.Y, size.Z))
|
||||
if longest > 0 {
|
||||
f := *fit / longest
|
||||
mesh.ApplyAll(targets, mesh.ScaleXYZ(mesh.Vec3{X: f, Y: f, Z: f}))
|
||||
}
|
||||
}
|
||||
|
||||
if *out == "" {
|
||||
*out = in
|
||||
}
|
||||
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
|
||||
}
|
||||
|
||||
func cmdMerge(args []string) {
|
||||
fs := flag.NewFlagSet("merge", flag.ExitOnError)
|
||||
out := fs.String("o", "", "output file")
|
||||
flatten := fs.Bool("flatten", false, "merge everything into a single object")
|
||||
ascii := fs.Bool("ascii", false, "write text STL")
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() < 2 {
|
||||
die("merge needs at least two input files")
|
||||
}
|
||||
if *out == "" {
|
||||
die("merge needs -o <out>")
|
||||
}
|
||||
scene := &mesh.Scene{}
|
||||
for _, path := range fs.Args() {
|
||||
s, err := mesh.ReadFile(path)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
scene.Append(s)
|
||||
}
|
||||
if *flatten {
|
||||
scene = &mesh.Scene{Meshes: []*mesh.Mesh{scene.Merged()}}
|
||||
}
|
||||
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
|
||||
}
|
||||
|
||||
func cmdConvert(args []string) {
|
||||
fs := flag.NewFlagSet("convert", flag.ExitOnError)
|
||||
out := fs.String("o", "", "output file")
|
||||
ascii := fs.Bool("ascii", false, "write text STL")
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() != 1 || *out == "" {
|
||||
die("convert takes one input file and -o <out>")
|
||||
}
|
||||
scene, err := mesh.ReadFile(fs.Arg(0))
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
if err := mesh.WriteFile(*out, scene, *ascii); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Printf("%s (%d objects, %d verts, %d tris)\n", *out, len(scene.Meshes), scene.TotalVerts(), scene.TotalTris())
|
||||
}
|
||||
|
||||
func die(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "mesht: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import "math"
|
||||
|
||||
// BBox returns the axis-aligned bounding box of the mesh.
|
||||
func (m *Mesh) BBox() (min, max Vec3) {
|
||||
if len(m.Verts) == 0 {
|
||||
return Vec3{}, Vec3{}
|
||||
}
|
||||
min, max = m.Verts[0], m.Verts[0]
|
||||
for _, v := range m.Verts[1:] {
|
||||
min = min.Min(v)
|
||||
max = max.Max(v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SurfaceArea sums the area of all triangles.
|
||||
func (m *Mesh) SurfaceArea() float64 {
|
||||
sum := 0.0
|
||||
for _, t := range m.Tris {
|
||||
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
|
||||
sum += b.Sub(a).Cross(c.Sub(a)).Len() / 2
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// SignedVolume computes the enclosed volume via the divergence theorem.
|
||||
// Only meaningful for closed meshes; positive when windings face outward.
|
||||
func (m *Mesh) SignedVolume() float64 {
|
||||
sum := 0.0
|
||||
for _, t := range m.Tris {
|
||||
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
|
||||
sum += a.Dot(b.Cross(c))
|
||||
}
|
||||
return sum / 6
|
||||
}
|
||||
|
||||
// Volume is the absolute enclosed volume.
|
||||
func (m *Mesh) Volume() float64 { return math.Abs(m.SignedVolume()) }
|
||||
|
||||
// SceneBBox returns the bounding box over the given meshes.
|
||||
func SceneBBox(meshes []*Mesh) (min, max Vec3) {
|
||||
first := true
|
||||
for _, m := range meshes {
|
||||
if len(m.Verts) == 0 {
|
||||
continue
|
||||
}
|
||||
mn, mx := m.BBox()
|
||||
if first {
|
||||
min, max = mn, mx
|
||||
first = false
|
||||
} else {
|
||||
min = min.Min(mn)
|
||||
max = max.Max(mx)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Triangle indexes three vertices, counter-clockwise seen from outside.
|
||||
type Triangle [3]int
|
||||
|
||||
// Mesh is one named object: a triangle soup over a shared vertex list.
|
||||
type Mesh struct {
|
||||
Name string
|
||||
Verts []Vec3
|
||||
Tris []Triangle
|
||||
}
|
||||
|
||||
// Scene is an ordered list of meshes, matching OBJ objects. STL files
|
||||
// load as a single-mesh scene.
|
||||
type Scene struct {
|
||||
Meshes []*Mesh
|
||||
}
|
||||
|
||||
func (s *Scene) TotalVerts() int {
|
||||
n := 0
|
||||
for _, m := range s.Meshes {
|
||||
n += len(m.Verts)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Scene) TotalTris() int {
|
||||
n := 0
|
||||
for _, m := range s.Meshes {
|
||||
n += len(m.Tris)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Mesh returns the named mesh, or nil.
|
||||
func (s *Scene) Mesh(name string) *Mesh {
|
||||
for _, m := range s.Meshes {
|
||||
if m.Name == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merged flattens the scene into a single mesh (copies data).
|
||||
func (s *Scene) Merged() *Mesh {
|
||||
out := &Mesh{Name: "merged"}
|
||||
for _, m := range s.Meshes {
|
||||
off := len(out.Verts)
|
||||
out.Verts = append(out.Verts, m.Verts...)
|
||||
for _, t := range m.Tris {
|
||||
out.Tris = append(out.Tris, Triangle{t[0] + off, t[1] + off, t[2] + off})
|
||||
}
|
||||
}
|
||||
if len(s.Meshes) == 1 {
|
||||
out.Name = s.Meshes[0].Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Append adds meshes from another scene, de-duplicating names by
|
||||
// appending _2, _3, ...
|
||||
func (s *Scene) Append(other *Scene) {
|
||||
taken := map[string]bool{}
|
||||
for _, m := range s.Meshes {
|
||||
taken[m.Name] = true
|
||||
}
|
||||
for _, m := range other.Meshes {
|
||||
name := m.Name
|
||||
for i := 2; taken[name]; i++ {
|
||||
name = fmt.Sprintf("%s_%d", m.Name, i)
|
||||
}
|
||||
m.Name = name
|
||||
taken[name] = true
|
||||
s.Meshes = append(s.Meshes, m)
|
||||
}
|
||||
}
|
||||
|
||||
// FaceNormal returns the (unit) normal of triangle i.
|
||||
func (m *Mesh) FaceNormal(i int) Vec3 {
|
||||
t := m.Tris[i]
|
||||
a, b, c := m.Verts[t[0]], m.Verts[t[1]], m.Verts[t[2]]
|
||||
return b.Sub(a).Cross(c.Sub(a)).Norm()
|
||||
}
|
||||
|
||||
// FlipWinding reverses the orientation of every triangle.
|
||||
func (m *Mesh) FlipWinding() {
|
||||
for i, t := range m.Tris {
|
||||
m.Tris[i] = Triangle{t[0], t[2], t[1]}
|
||||
}
|
||||
}
|
||||
|
||||
type edge struct{ a, b int }
|
||||
|
||||
func normEdge(a, b int) edge {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edge{a, b}
|
||||
}
|
||||
|
||||
// EdgeStats classifies the mesh topology: boundary edges belong to one
|
||||
// triangle, manifold edges to two, anything more is non-manifold. A
|
||||
// closed (watertight) mesh has zero boundary and zero non-manifold edges.
|
||||
func (m *Mesh) EdgeStats() (boundary, nonManifold int) {
|
||||
count := map[edge]int{}
|
||||
for _, t := range m.Tris {
|
||||
count[normEdge(t[0], t[1])]++
|
||||
count[normEdge(t[1], t[2])]++
|
||||
count[normEdge(t[2], t[0])]++
|
||||
}
|
||||
for _, n := range count {
|
||||
switch {
|
||||
case n == 1:
|
||||
boundary++
|
||||
case n > 2:
|
||||
nonManifold++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Closed reports whether the mesh is watertight.
|
||||
func (m *Mesh) Closed() bool {
|
||||
b, nm := m.EdgeStats()
|
||||
return b == 0 && nm == 0
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func almost(t *testing.T, name string, got, want, tol float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > tol {
|
||||
t.Errorf("%s = %g, want %g (±%g)", name, got, want, tol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimitiveVolumes(t *testing.T) {
|
||||
box := Box(Vec3{1, 2, 3})
|
||||
almost(t, "box volume", box.SignedVolume(), 6, 1e-9)
|
||||
almost(t, "box area", box.SurfaceArea(), 22, 1e-9)
|
||||
if !box.Closed() {
|
||||
t.Error("box should be watertight")
|
||||
}
|
||||
|
||||
sph := Sphere(1, 64, 32)
|
||||
almost(t, "sphere volume", sph.SignedVolume(), 4*math.Pi/3, 0.07)
|
||||
almost(t, "sphere area", sph.SurfaceArea(), 4*math.Pi, 0.15)
|
||||
if !sph.Closed() {
|
||||
t.Error("sphere should be watertight")
|
||||
}
|
||||
|
||||
cyl := Cylinder(0.5, 2, 64)
|
||||
almost(t, "cylinder volume", cyl.SignedVolume(), math.Pi*0.25*2, 0.01)
|
||||
if !cyl.Closed() {
|
||||
t.Error("cylinder should be watertight")
|
||||
}
|
||||
|
||||
cone := Cone(1, 3, 64)
|
||||
almost(t, "cone volume", cone.SignedVolume(), math.Pi/3*3, 0.02)
|
||||
if !cone.Closed() {
|
||||
t.Error("cone should be watertight")
|
||||
}
|
||||
|
||||
tor := Torus(2, 0.5, 64, 32)
|
||||
almost(t, "torus volume", tor.SignedVolume(), 2*math.Pi*math.Pi*2*0.25, 0.25)
|
||||
if !tor.Closed() {
|
||||
t.Error("torus should be watertight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBBoxAndMeasure(t *testing.T) {
|
||||
box := Box(Vec3{2, 4, 6})
|
||||
mn, mx := box.BBox()
|
||||
if mn != (Vec3{-1, -2, -3}) || mx != (Vec3{1, 2, 3}) {
|
||||
t.Errorf("bbox = %v..%v", mn, mx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformMirrorKeepsVolumePositive(t *testing.T) {
|
||||
box := Box(Vec3{1, 1, 1})
|
||||
box.Apply(ScaleXYZ(Vec3{-1, 1, 1}))
|
||||
almost(t, "mirrored box volume", box.SignedVolume(), 1, 1e-9)
|
||||
box.Apply(RotateY(45).Mul(RotateX(30)))
|
||||
almost(t, "rotated box volume", box.SignedVolume(), 1, 1e-9)
|
||||
box.Apply(Translate(Vec3{10, -5, 3}))
|
||||
almost(t, "translated box volume", box.SignedVolume(), 1, 1e-6)
|
||||
}
|
||||
|
||||
func TestOBJRoundTrip(t *testing.T) {
|
||||
scene := &Scene{Meshes: []*Mesh{Box(Vec3{1, 2, 3}), Sphere(1, 8, 4)}}
|
||||
scene.Meshes[0].Name = "crate"
|
||||
scene.Meshes[1].Name = "ball"
|
||||
var buf bytes.Buffer
|
||||
if err := WriteOBJ(&buf, scene); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := ReadOBJ(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(back.Meshes) != 2 {
|
||||
t.Fatalf("got %d meshes, want 2", len(back.Meshes))
|
||||
}
|
||||
if back.Meshes[0].Name != "crate" || back.Meshes[1].Name != "ball" {
|
||||
t.Errorf("names = %q, %q", back.Meshes[0].Name, back.Meshes[1].Name)
|
||||
}
|
||||
almost(t, "roundtrip crate volume", back.Meshes[0].SignedVolume(), 6, 1e-9)
|
||||
if !back.Meshes[1].Closed() {
|
||||
t.Error("roundtripped sphere should stay watertight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOBJQuadsAndNegativeIndices(t *testing.T) {
|
||||
src := `
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 1 1 0
|
||||
v 0 1 0
|
||||
f 1 2 3 4
|
||||
f -4 -3 -2
|
||||
`
|
||||
s, err := ReadOBJ(strings.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.TotalTris(); got != 3 {
|
||||
t.Errorf("tris = %d, want 3 (quad fan + one negative-index tri)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTLRoundTrips(t *testing.T) {
|
||||
box := Box(Vec3{1, 2, 3})
|
||||
scene := &Scene{Meshes: []*Mesh{box}}
|
||||
|
||||
var bin bytes.Buffer
|
||||
if err := WriteSTLBinary(&bin, scene); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := ReadSTL(bytes.NewReader(bin.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := back.Meshes[0]
|
||||
if len(m.Verts) != 8 {
|
||||
t.Errorf("binary stl weld: %d verts, want 8", len(m.Verts))
|
||||
}
|
||||
almost(t, "binary stl volume", m.SignedVolume(), 6, 1e-6)
|
||||
|
||||
var asc bytes.Buffer
|
||||
if err := WriteSTLAscii(&asc, scene); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back2, err := ReadSTL(bytes.NewReader(asc.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
almost(t, "ascii stl volume", back2.Meshes[0].SignedVolume(), 6, 1e-6)
|
||||
if !back2.Meshes[0].Closed() {
|
||||
t.Error("ascii stl roundtrip should stay watertight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneAppendRenames(t *testing.T) {
|
||||
a := &Scene{Meshes: []*Mesh{Box(Vec3{1, 1, 1})}}
|
||||
b := &Scene{Meshes: []*Mesh{Box(Vec3{2, 2, 2})}}
|
||||
a.Append(b)
|
||||
if a.Meshes[0].Name == a.Meshes[1].Name {
|
||||
t.Errorf("duplicate names after append: %q", a.Meshes[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderASCII(t *testing.T) {
|
||||
sph := Sphere(1, 32, 16)
|
||||
out := RenderASCII(sph, Views["front"], 40)
|
||||
if !strings.Contains(out, "front view") {
|
||||
t.Errorf("missing header:\n%s", out)
|
||||
}
|
||||
ink := 0
|
||||
for _, r := range out {
|
||||
if strings.ContainsRune(shadeRamp[1:], r) {
|
||||
ink++
|
||||
}
|
||||
}
|
||||
if ink < 100 {
|
||||
t.Errorf("sphere render suspiciously empty (%d shaded cells):\n%s", ink, out)
|
||||
}
|
||||
// a sphere should be roughly as tall as wide after aspect correction
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
rows := len(lines) - 2 // minus the two header lines
|
||||
if rows < 15 || rows > 25 {
|
||||
t.Errorf("40-wide sphere should be ~20 rows, got %d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeStatsOpenMesh(t *testing.T) {
|
||||
p := Plane(1, 1)
|
||||
if p.Closed() {
|
||||
t.Error("plane must not be watertight")
|
||||
}
|
||||
b, nm := p.EdgeStats()
|
||||
if b != 4 || nm != 0 {
|
||||
t.Errorf("plane edge stats = %d boundary, %d non-manifold; want 4, 0", b, nm)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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:])
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import "math"
|
||||
|
||||
// All primitives are centered at the origin with +Y up and get outward
|
||||
// (counter-clockwise) winding; ensureOutward fixes the global
|
||||
// orientation via the signed volume as a safety net.
|
||||
|
||||
func ensureOutward(m *Mesh) *Mesh {
|
||||
if m.SignedVolume() < 0 {
|
||||
m.FlipWinding()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Box builds an axis-aligned box of the given size.
|
||||
func Box(size Vec3) *Mesh {
|
||||
x, y, z := size.X/2, size.Y/2, size.Z/2
|
||||
m := &Mesh{
|
||||
Name: "box",
|
||||
Verts: []Vec3{
|
||||
{-x, -y, -z}, {x, -y, -z}, {x, y, -z}, {-x, y, -z}, // back (z-)
|
||||
{-x, -y, z}, {x, -y, z}, {x, y, z}, {-x, y, z}, // front (z+)
|
||||
},
|
||||
}
|
||||
quads := [][4]int{
|
||||
{0, 3, 2, 1}, // back
|
||||
{4, 5, 6, 7}, // front
|
||||
{0, 1, 5, 4}, // bottom
|
||||
{2, 3, 7, 6}, // top
|
||||
{1, 2, 6, 5}, // right
|
||||
{0, 4, 7, 3}, // left
|
||||
}
|
||||
for _, q := range quads {
|
||||
m.Tris = append(m.Tris, Triangle{q[0], q[1], q[2]}, Triangle{q[0], q[2], q[3]})
|
||||
}
|
||||
return ensureOutward(m)
|
||||
}
|
||||
|
||||
// Plane builds a flat rectangle in the XZ plane (an open mesh).
|
||||
func Plane(w, d float64) *Mesh {
|
||||
x, z := w/2, d/2
|
||||
return &Mesh{
|
||||
Name: "plane",
|
||||
Verts: []Vec3{{-x, 0, -z}, {x, 0, -z}, {x, 0, z}, {-x, 0, z}},
|
||||
Tris: []Triangle{{0, 2, 1}, {0, 3, 2}}, // +Y facing
|
||||
}
|
||||
}
|
||||
|
||||
// Sphere builds a UV sphere. segments = around the equator (>= 3),
|
||||
// rings = from pole to pole (>= 2).
|
||||
func Sphere(r float64, segments, rings int) *Mesh {
|
||||
if segments < 3 {
|
||||
segments = 3
|
||||
}
|
||||
if rings < 2 {
|
||||
rings = 2
|
||||
}
|
||||
m := &Mesh{Name: "sphere"}
|
||||
top := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, r, 0})
|
||||
// interior rings, top to bottom
|
||||
ringStart := make([]int, rings)
|
||||
for i := 1; i < rings; i++ {
|
||||
theta := math.Pi * float64(i) / float64(rings)
|
||||
y := r * math.Cos(theta)
|
||||
rad := r * math.Sin(theta)
|
||||
ringStart[i] = len(m.Verts)
|
||||
for j := 0; j < segments; j++ {
|
||||
phi := 2 * math.Pi * float64(j) / float64(segments)
|
||||
m.Verts = append(m.Verts, Vec3{rad * math.Cos(phi), y, rad * math.Sin(phi)})
|
||||
}
|
||||
}
|
||||
bottom := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, -r, 0})
|
||||
|
||||
at := func(ring, seg int) int { return ringStart[ring] + seg%segments }
|
||||
for j := 0; j < segments; j++ {
|
||||
m.Tris = append(m.Tris, Triangle{top, at(1, j), at(1, j+1)}) // top cap
|
||||
m.Tris = append(m.Tris, Triangle{bottom, at(rings-1, j+1), at(rings-1, j)})
|
||||
}
|
||||
for i := 1; i < rings-1; i++ {
|
||||
for j := 0; j < segments; j++ {
|
||||
a, b := at(i, j), at(i, j+1)
|
||||
c, d := at(i+1, j+1), at(i+1, j)
|
||||
m.Tris = append(m.Tris, Triangle{a, b, c}, Triangle{a, c, d})
|
||||
}
|
||||
}
|
||||
return ensureOutward(m)
|
||||
}
|
||||
|
||||
// Cylinder builds a closed cylinder of height h around the Y axis.
|
||||
func Cylinder(r, h float64, segments int) *Mesh {
|
||||
if segments < 3 {
|
||||
segments = 3
|
||||
}
|
||||
m := &Mesh{Name: "cylinder"}
|
||||
y := h / 2
|
||||
topC := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, y, 0})
|
||||
botC := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, -y, 0})
|
||||
topStart := len(m.Verts)
|
||||
for j := 0; j < segments; j++ {
|
||||
phi := 2 * math.Pi * float64(j) / float64(segments)
|
||||
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), y, r * math.Sin(phi)})
|
||||
}
|
||||
botStart := len(m.Verts)
|
||||
for j := 0; j < segments; j++ {
|
||||
phi := 2 * math.Pi * float64(j) / float64(segments)
|
||||
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), -y, r * math.Sin(phi)})
|
||||
}
|
||||
t := func(j int) int { return topStart + j%segments }
|
||||
b := func(j int) int { return botStart + j%segments }
|
||||
for j := 0; j < segments; j++ {
|
||||
m.Tris = append(m.Tris,
|
||||
Triangle{topC, t(j + 1), t(j)}, // top cap
|
||||
Triangle{botC, b(j), b(j + 1)}, // bottom cap
|
||||
Triangle{t(j), t(j + 1), b(j + 1)}, // side
|
||||
Triangle{t(j), b(j + 1), b(j)}, // side
|
||||
)
|
||||
}
|
||||
return ensureOutward(m)
|
||||
}
|
||||
|
||||
// Cone builds a closed cone with its base at -h/2 and apex at +h/2.
|
||||
func Cone(r, h float64, segments int) *Mesh {
|
||||
if segments < 3 {
|
||||
segments = 3
|
||||
}
|
||||
m := &Mesh{Name: "cone"}
|
||||
apex := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, h / 2, 0})
|
||||
baseC := len(m.Verts)
|
||||
m.Verts = append(m.Verts, Vec3{0, -h / 2, 0})
|
||||
start := len(m.Verts)
|
||||
for j := 0; j < segments; j++ {
|
||||
phi := 2 * math.Pi * float64(j) / float64(segments)
|
||||
m.Verts = append(m.Verts, Vec3{r * math.Cos(phi), -h / 2, r * math.Sin(phi)})
|
||||
}
|
||||
at := func(j int) int { return start + j%segments }
|
||||
for j := 0; j < segments; j++ {
|
||||
m.Tris = append(m.Tris,
|
||||
Triangle{apex, at(j + 1), at(j)},
|
||||
Triangle{baseC, at(j), at(j + 1)},
|
||||
)
|
||||
}
|
||||
return ensureOutward(m)
|
||||
}
|
||||
|
||||
// Torus builds a torus around the Y axis: ring radius R (center of tube
|
||||
// to center of torus) and tube radius r.
|
||||
func Torus(R, r float64, segments, rings int) *Mesh {
|
||||
if segments < 3 {
|
||||
segments = 3
|
||||
}
|
||||
if rings < 3 {
|
||||
rings = 3
|
||||
}
|
||||
m := &Mesh{Name: "torus"}
|
||||
for i := 0; i < segments; i++ { // around the main ring
|
||||
phi := 2 * math.Pi * float64(i) / float64(segments)
|
||||
cx, cz := math.Cos(phi), math.Sin(phi)
|
||||
for j := 0; j < rings; j++ { // around the tube
|
||||
theta := 2 * math.Pi * float64(j) / float64(rings)
|
||||
rad := R + r*math.Cos(theta)
|
||||
m.Verts = append(m.Verts, Vec3{rad * cx, r * math.Sin(theta), rad * cz})
|
||||
}
|
||||
}
|
||||
at := func(i, j int) int { return (i%segments)*rings + j%rings }
|
||||
for i := 0; i < segments; i++ {
|
||||
for j := 0; j < rings; j++ {
|
||||
a, b := at(i, j), at(i+1, j)
|
||||
c, d := at(i+1, j+1), at(i, j+1)
|
||||
m.Tris = append(m.Tris, Triangle{a, b, c}, Triangle{a, c, d})
|
||||
}
|
||||
}
|
||||
return ensureOutward(m)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadSTL reads binary or ASCII STL (auto-detected). STL stores loose
|
||||
// triangles, so identical vertices are welded back together to recover
|
||||
// connectivity (needed for watertight checks and sane OBJ export).
|
||||
func ReadSTL(r io.Reader) (*Scene, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) >= 84 {
|
||||
n := binary.LittleEndian.Uint32(data[80:84])
|
||||
if int(84+50*n) == len(data) {
|
||||
return readSTLBinary(data)
|
||||
}
|
||||
}
|
||||
if bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte("solid")) {
|
||||
return readSTLAscii(data)
|
||||
}
|
||||
return nil, fmt.Errorf("not a valid STL file (neither binary layout nor 'solid ...' text)")
|
||||
}
|
||||
|
||||
type welder struct {
|
||||
mesh *Mesh
|
||||
index map[Vec3]int
|
||||
}
|
||||
|
||||
func newWelder(name string) *welder {
|
||||
return &welder{mesh: &Mesh{Name: name}, index: map[Vec3]int{}}
|
||||
}
|
||||
|
||||
func (w *welder) add(a, b, c Vec3) {
|
||||
var t Triangle
|
||||
for i, v := range [3]Vec3{a, b, c} {
|
||||
idx, ok := w.index[v]
|
||||
if !ok {
|
||||
idx = len(w.mesh.Verts)
|
||||
w.mesh.Verts = append(w.mesh.Verts, v)
|
||||
w.index[v] = idx
|
||||
}
|
||||
t[i] = idx
|
||||
}
|
||||
if t[0] == t[1] || t[1] == t[2] || t[2] == t[0] {
|
||||
return // degenerate
|
||||
}
|
||||
w.mesh.Tris = append(w.mesh.Tris, t)
|
||||
}
|
||||
|
||||
func readSTLBinary(data []byte) (*Scene, error) {
|
||||
n := int(binary.LittleEndian.Uint32(data[80:84]))
|
||||
w := newWelder("stl")
|
||||
off := 84
|
||||
f32 := func(o int) float64 {
|
||||
return float64(math.Float32frombits(binary.LittleEndian.Uint32(data[o : o+4])))
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
// 12 bytes normal (ignored), 3 * 12 bytes vertices, 2 bytes attrs
|
||||
var v [3]Vec3
|
||||
for k := 0; k < 3; k++ {
|
||||
base := off + 12 + k*12
|
||||
v[k] = Vec3{f32(base), f32(base + 4), f32(base + 8)}
|
||||
}
|
||||
w.add(v[0], v[1], v[2])
|
||||
off += 50
|
||||
}
|
||||
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
|
||||
}
|
||||
|
||||
func readSTLAscii(data []byte) (*Scene, error) {
|
||||
name := "stl"
|
||||
w := newWelder(name)
|
||||
var cur []Vec3
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "solid":
|
||||
if len(fields) > 1 {
|
||||
w.mesh.Name = fields[1]
|
||||
}
|
||||
case "vertex":
|
||||
if len(fields) < 4 {
|
||||
return nil, fmt.Errorf("stl 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("stl line %d: bad vertex: %v", lineNo, err)
|
||||
}
|
||||
cur = append(cur, v)
|
||||
case "endfacet":
|
||||
if len(cur) != 3 {
|
||||
return nil, fmt.Errorf("stl line %d: facet has %d vertices, want 3", lineNo, len(cur))
|
||||
}
|
||||
w.add(cur[0], cur[1], cur[2])
|
||||
cur = cur[:0]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(w.mesh.Tris) == 0 {
|
||||
return nil, fmt.Errorf("stl contains no triangles")
|
||||
}
|
||||
return &Scene{Meshes: []*Mesh{w.mesh}}, nil
|
||||
}
|
||||
|
||||
// WriteSTLBinary writes the whole scene as one binary STL solid
|
||||
// (STL has no concept of multiple named objects).
|
||||
func WriteSTLBinary(w io.Writer, s *Scene) error {
|
||||
m := s.Merged()
|
||||
bw := bufio.NewWriter(w)
|
||||
header := make([]byte, 80)
|
||||
copy(header, []byte("exported by mesht (agent-tools)"))
|
||||
bw.Write(header)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(m.Tris)))
|
||||
buf := make([]byte, 50)
|
||||
for i, t := range m.Tris {
|
||||
n := m.FaceNormal(i)
|
||||
le := binary.LittleEndian
|
||||
le.PutUint32(buf[0:], math.Float32bits(float32(n.X)))
|
||||
le.PutUint32(buf[4:], math.Float32bits(float32(n.Y)))
|
||||
le.PutUint32(buf[8:], math.Float32bits(float32(n.Z)))
|
||||
for k := 0; k < 3; k++ {
|
||||
v := m.Verts[t[k]]
|
||||
le.PutUint32(buf[12+k*12:], math.Float32bits(float32(v.X)))
|
||||
le.PutUint32(buf[16+k*12:], math.Float32bits(float32(v.Y)))
|
||||
le.PutUint32(buf[20+k*12:], math.Float32bits(float32(v.Z)))
|
||||
}
|
||||
buf[48], buf[49] = 0, 0
|
||||
bw.Write(buf)
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// WriteSTLAscii writes the scene as a text STL solid.
|
||||
func WriteSTLAscii(w io.Writer, s *Scene) error {
|
||||
m := s.Merged()
|
||||
bw := bufio.NewWriter(w)
|
||||
fmt.Fprintf(bw, "solid %s\n", sanitizeToken(m.Name))
|
||||
for i, t := range m.Tris {
|
||||
n := m.FaceNormal(i)
|
||||
fmt.Fprintf(bw, " facet normal %g %g %g\n outer loop\n", n.X, n.Y, n.Z)
|
||||
for k := 0; k < 3; k++ {
|
||||
v := m.Verts[t[k]]
|
||||
fmt.Fprintf(bw, " vertex %g %g %g\n", v.X, v.Y, v.Z)
|
||||
}
|
||||
fmt.Fprintf(bw, " endloop\n endfacet\n")
|
||||
}
|
||||
fmt.Fprintf(bw, "endsolid %s\n", sanitizeToken(m.Name))
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func sanitizeToken(s string) string {
|
||||
s = strings.ReplaceAll(strings.TrimSpace(s), " ", "_")
|
||||
if s == "" {
|
||||
return "mesh"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package mesh
|
||||
|
||||
// Apply transforms every vertex by mat. Mirroring transforms (negative
|
||||
// determinant) flip triangle winding, so it is corrected here to keep
|
||||
// normals pointing the same way relative to the surface.
|
||||
func (m *Mesh) Apply(mat Mat4) {
|
||||
for i := range m.Verts {
|
||||
m.Verts[i] = mat.Apply(m.Verts[i])
|
||||
}
|
||||
if mat.Det3() < 0 {
|
||||
m.FlipWinding()
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyAll transforms a set of meshes.
|
||||
func ApplyAll(meshes []*Mesh, mat Mat4) {
|
||||
for _, m := range meshes {
|
||||
m.Apply(mat)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import "math"
|
||||
|
||||
// Vec3 is a point or direction in 3D space.
|
||||
type Vec3 struct{ X, Y, Z float64 }
|
||||
|
||||
func (a Vec3) Add(b Vec3) Vec3 { return Vec3{a.X + b.X, a.Y + b.Y, a.Z + b.Z} }
|
||||
func (a Vec3) Sub(b Vec3) Vec3 { return Vec3{a.X - b.X, a.Y - b.Y, a.Z - b.Z} }
|
||||
func (a Vec3) Mul(s float64) Vec3 { return Vec3{a.X * s, a.Y * s, a.Z * s} }
|
||||
func (a Vec3) Dot(b Vec3) float64 { return a.X*b.X + a.Y*b.Y + a.Z*b.Z }
|
||||
func (a Vec3) Len() float64 { return math.Sqrt(a.Dot(a)) }
|
||||
func (a Vec3) Cross(b Vec3) Vec3 {
|
||||
return Vec3{
|
||||
a.Y*b.Z - a.Z*b.Y,
|
||||
a.Z*b.X - a.X*b.Z,
|
||||
a.X*b.Y - a.Y*b.X,
|
||||
}
|
||||
}
|
||||
|
||||
// Norm returns the unit vector, or the zero vector for zero-length input.
|
||||
func (a Vec3) Norm() Vec3 {
|
||||
l := a.Len()
|
||||
if l == 0 {
|
||||
return Vec3{}
|
||||
}
|
||||
return a.Mul(1 / l)
|
||||
}
|
||||
|
||||
// Min/Max return the component-wise minimum/maximum.
|
||||
func (a Vec3) Min(b Vec3) Vec3 {
|
||||
return Vec3{math.Min(a.X, b.X), math.Min(a.Y, b.Y), math.Min(a.Z, b.Z)}
|
||||
}
|
||||
func (a Vec3) Max(b Vec3) Vec3 {
|
||||
return Vec3{math.Max(a.X, b.X), math.Max(a.Y, b.Y), math.Max(a.Z, b.Z)}
|
||||
}
|
||||
|
||||
// Mat4 is a row-major 4x4 transform matrix.
|
||||
type Mat4 [16]float64
|
||||
|
||||
func Identity() Mat4 {
|
||||
return Mat4{
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Mul returns m * n (n is applied first when transforming points).
|
||||
func (m Mat4) Mul(n Mat4) Mat4 {
|
||||
var r Mat4
|
||||
for row := 0; row < 4; row++ {
|
||||
for col := 0; col < 4; col++ {
|
||||
sum := 0.0
|
||||
for k := 0; k < 4; k++ {
|
||||
sum += m[row*4+k] * n[k*4+col]
|
||||
}
|
||||
r[row*4+col] = sum
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Apply transforms a point (w = 1).
|
||||
func (m Mat4) Apply(v Vec3) Vec3 {
|
||||
return Vec3{
|
||||
m[0]*v.X + m[1]*v.Y + m[2]*v.Z + m[3],
|
||||
m[4]*v.X + m[5]*v.Y + m[6]*v.Z + m[7],
|
||||
m[8]*v.X + m[9]*v.Y + m[10]*v.Z + m[11],
|
||||
}
|
||||
}
|
||||
|
||||
// Det3 is the determinant of the upper-left 3x3. Negative means the
|
||||
// transform mirrors space, which flips triangle winding.
|
||||
func (m Mat4) Det3() float64 {
|
||||
return m[0]*(m[5]*m[10]-m[6]*m[9]) -
|
||||
m[1]*(m[4]*m[10]-m[6]*m[8]) +
|
||||
m[2]*(m[4]*m[9]-m[5]*m[8])
|
||||
}
|
||||
|
||||
func Translate(t Vec3) Mat4 {
|
||||
m := Identity()
|
||||
m[3], m[7], m[11] = t.X, t.Y, t.Z
|
||||
return m
|
||||
}
|
||||
|
||||
func ScaleXYZ(s Vec3) Mat4 {
|
||||
m := Identity()
|
||||
m[0], m[5], m[10] = s.X, s.Y, s.Z
|
||||
return m
|
||||
}
|
||||
|
||||
func RotateX(deg float64) Mat4 {
|
||||
s, c := math.Sincos(deg * math.Pi / 180)
|
||||
return Mat4{
|
||||
1, 0, 0, 0,
|
||||
0, c, -s, 0,
|
||||
0, s, c, 0,
|
||||
0, 0, 0, 1,
|
||||
}
|
||||
}
|
||||
|
||||
func RotateY(deg float64) Mat4 {
|
||||
s, c := math.Sincos(deg * math.Pi / 180)
|
||||
return Mat4{
|
||||
c, 0, s, 0,
|
||||
0, 1, 0, 0,
|
||||
-s, 0, c, 0,
|
||||
0, 0, 0, 1,
|
||||
}
|
||||
}
|
||||
|
||||
func RotateZ(deg float64) Mat4 {
|
||||
s, c := math.Sincos(deg * math.Pi / 180)
|
||||
return Mat4{
|
||||
c, -s, 0, 0,
|
||||
s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
}
|
||||
}
|
||||
86
pixel-sprite-maker/README.md
Normal file
86
pixel-sprite-maker/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# pixel-sprite-maker (`spritec`)
|
||||
|
||||
Turns human/agent-readable `.sprite` text files into pixel art (**PNG, JPG, SVG**)
|
||||
and combines several sprites into **sprite sheets / animation strips**.
|
||||
Written in Go, zero dependencies, single static binary.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Arch/Garuda: sudo pacman -S go
|
||||
cd pixel-sprite-maker
|
||||
go build -o build/spritec . # or run the VS Code task "build pixel-sprite-maker"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## The `.sprite` format
|
||||
|
||||
One file = one sprite. Designed so an agent can *see* the result in the text:
|
||||
|
||||
```
|
||||
# comment lines start with '#'
|
||||
sprite: coin # optional name
|
||||
palette:
|
||||
k = #1A1205 dark outline
|
||||
y = #FFD700 gold
|
||||
Y = gold CSS names work too — text after the color is a comment
|
||||
grid:
|
||||
..kkkk..
|
||||
.kyyyYk.
|
||||
kyyyYYyk
|
||||
..kkkk..
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One character = one pixel.** Every grid row must be the same width.
|
||||
- Palette keys are exactly one character (any unicode). `#`, `=`, `:` and
|
||||
whitespace are not allowed as keys — so the number of colors is
|
||||
practically unlimited (a-z, A-Z, 0-9, `!@%&*`, unicode…).
|
||||
- `.` is **transparent by default**; override it in the palette if you want.
|
||||
- Colors: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`,
|
||||
`rgba(r,g,b,a)` (alpha 0-255 or 0.0-1.0), all CSS named colors, `none`.
|
||||
- Max **256x256** pixels per sprite. Sheets may exceed this; single frames may not.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
spritec render coin.sprite -o coin.png --scale 8 # png/jpg/svg from extension
|
||||
spritec render coin.sprite --format svg # -> coin.svg
|
||||
spritec render heart.sprite -o heart.jpg --bg '#222034' # jpg has no alpha: pick bg
|
||||
spritec info coin.sprite # validate + palette stats
|
||||
spritec preview coin.sprite # draw in the terminal (truecolor)
|
||||
|
||||
# Sprite sheets / animations — row-major (left→right, then top→bottom):
|
||||
spritec sheet walk_1.sprite walk_2.sprite walk_3.sprite walk_4.sprite -o walk # 1 row
|
||||
spritec sheet walk_*.sprite --cols 2 -o walkgrid # 2D: 2 columns x 2 rows
|
||||
```
|
||||
|
||||
Flags: `-o` output, `--format png|jpg|svg`, `--scale n` (integer nearest-neighbour
|
||||
upscale), `--bg color` + `--quality 1-100` (jpg only), `--cols n` (sheet only).
|
||||
|
||||
## Sheet output naming — read the layout from the file name
|
||||
|
||||
```
|
||||
<name>_<cellW>x<cellH>_<cols>x<rows>.<ext>
|
||||
|
||||
walk_8x8_4x1.png = 8x8 px per frame, 4 columns, 1 row (animation strip)
|
||||
tiles_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows
|
||||
```
|
||||
|
||||
Frame order is always **row-major**: index = `row * cols + col`, frame 0 is
|
||||
top-left. If the frame count doesn't fill the grid, trailing cells are
|
||||
transparent. In game code:
|
||||
|
||||
```
|
||||
frameX = (index % cols) * cellW
|
||||
frameY = (index / cols) * cellH
|
||||
```
|
||||
|
||||
## Exit codes & errors
|
||||
|
||||
`0` on success, `1` on any error, `2` on missing command. Parse errors name
|
||||
the exact line/row/column and what was expected, so an agent can fix the
|
||||
file without guessing.
|
||||
|
||||
Examples live in [`examples/`](examples/); generated output in `examples/out/`.
|
||||
17
pixel-sprite-maker/examples/coin.sprite
Normal file
17
pixel-sprite-maker/examples/coin.sprite
Normal file
@@ -0,0 +1,17 @@
|
||||
# A small gold coin, 8x8.
|
||||
# '.' is transparent by default and never needs a palette entry.
|
||||
sprite: coin
|
||||
palette:
|
||||
k = #1A1205 dark outline
|
||||
y = #FFD700 gold
|
||||
Y = #FFF3A0 highlight
|
||||
s = #B8860B shadow
|
||||
grid:
|
||||
..kkkk..
|
||||
.kyyyYk.
|
||||
kyyyYYsk
|
||||
kyyYyysk
|
||||
kyYyyysk
|
||||
kYyyyssk
|
||||
.kysssk.
|
||||
..kkkk..
|
||||
25
pixel-sprite-maker/examples/heart.sprite
Normal file
25
pixel-sprite-maker/examples/heart.sprite
Normal file
@@ -0,0 +1,25 @@
|
||||
# A 16x16 heart with shading, shows named colors and rgb().
|
||||
sprite: heart
|
||||
palette:
|
||||
k = #2B0A10 outline
|
||||
r = crimson main red
|
||||
R = rgb(255, 90, 110) light red
|
||||
d = #8B1A2B dark red
|
||||
w = #FFFFFFCC semi-transparent shine
|
||||
grid:
|
||||
................
|
||||
..kkk....kkk....
|
||||
.krrRk..kRrrk...
|
||||
krrRRrkkrRrrrk..
|
||||
krRwRrrrrrrrdk..
|
||||
krRwwRrrrrrrdk..
|
||||
krRwRrrrrrrddk..
|
||||
krrRrrrrrrrddk..
|
||||
.krrrrrrrrrdk...
|
||||
..krrrrrrrdk....
|
||||
...krrrrrdk.....
|
||||
....krrrdk......
|
||||
.....krdk.......
|
||||
......kk........
|
||||
................
|
||||
................
|
||||
BIN
pixel-sprite-maker/examples/out/coin.png
Normal file
BIN
pixel-sprite-maker/examples/out/coin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 344 B |
34
pixel-sprite-maker/examples/out/coin.svg
Normal file
34
pixel-sprite-maker/examples/out/coin.svg
Normal file
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges">
|
||||
<rect x="2" y="0" width="4" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="1" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="2" y="1" width="3" height="1" fill="#ffd700"/>
|
||||
<rect x="5" y="1" width="1" height="1" fill="#fff3a0"/>
|
||||
<rect x="6" y="1" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="0" y="2" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="2" width="3" height="1" fill="#ffd700"/>
|
||||
<rect x="4" y="2" width="2" height="1" fill="#fff3a0"/>
|
||||
<rect x="6" y="2" width="1" height="1" fill="#b8860b"/>
|
||||
<rect x="7" y="2" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="0" y="3" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="3" width="2" height="1" fill="#ffd700"/>
|
||||
<rect x="3" y="3" width="1" height="1" fill="#fff3a0"/>
|
||||
<rect x="4" y="3" width="2" height="1" fill="#ffd700"/>
|
||||
<rect x="6" y="3" width="1" height="1" fill="#b8860b"/>
|
||||
<rect x="7" y="3" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="0" y="4" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="4" width="1" height="1" fill="#ffd700"/>
|
||||
<rect x="2" y="4" width="1" height="1" fill="#fff3a0"/>
|
||||
<rect x="3" y="4" width="3" height="1" fill="#ffd700"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="#b8860b"/>
|
||||
<rect x="7" y="4" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="0" y="5" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="5" width="1" height="1" fill="#fff3a0"/>
|
||||
<rect x="2" y="5" width="3" height="1" fill="#ffd700"/>
|
||||
<rect x="5" y="5" width="2" height="1" fill="#b8860b"/>
|
||||
<rect x="7" y="5" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="1" y="6" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="2" y="6" width="1" height="1" fill="#ffd700"/>
|
||||
<rect x="3" y="6" width="3" height="1" fill="#b8860b"/>
|
||||
<rect x="6" y="6" width="1" height="1" fill="#1a1205"/>
|
||||
<rect x="2" y="7" width="4" height="1" fill="#1a1205"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
pixel-sprite-maker/examples/out/heart.jpg
Normal file
BIN
pixel-sprite-maker/examples/out/heart.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
pixel-sprite-maker/examples/out/walk_8x8_4x1.png
Normal file
BIN
pixel-sprite-maker/examples/out/walk_8x8_4x1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 508 B |
57
pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg
Normal file
57
pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg
Normal file
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" shape-rendering="crispEdges">
|
||||
<rect x="2" y="0" width="3" height="1" fill="#101018"/>
|
||||
<rect x="10" y="0" width="3" height="1" fill="#101018"/>
|
||||
<rect x="2" y="1" width="1" height="1" fill="#101018"/>
|
||||
<rect x="3" y="1" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="10" y="1" width="1" height="1" fill="#101018"/>
|
||||
<rect x="11" y="1" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="2" y="2" width="1" height="1" fill="#101018"/>
|
||||
<rect x="3" y="2" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="10" y="2" width="1" height="1" fill="#101018"/>
|
||||
<rect x="11" y="2" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="1" y="3" width="1" height="1" fill="#101018"/>
|
||||
<rect x="2" y="3" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="5" y="3" width="1" height="1" fill="#101018"/>
|
||||
<rect x="9" y="3" width="1" height="1" fill="#101018"/>
|
||||
<rect x="10" y="3" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="13" y="3" width="1" height="1" fill="#101018"/>
|
||||
<rect x="2" y="4" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="10" y="4" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="2" y="5" width="3" height="1" fill="#22406e"/>
|
||||
<rect x="10" y="5" width="3" height="1" fill="#22406e"/>
|
||||
<rect x="2" y="6" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="4" y="6" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="9" y="6" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="13" y="6" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="2" y="7" width="1" height="1" fill="#101018"/>
|
||||
<rect x="4" y="7" width="1" height="1" fill="#101018"/>
|
||||
<rect x="9" y="7" width="1" height="1" fill="#101018"/>
|
||||
<rect x="13" y="7" width="1" height="1" fill="#101018"/>
|
||||
<rect x="2" y="8" width="3" height="1" fill="#101018"/>
|
||||
<rect x="10" y="8" width="3" height="1" fill="#101018"/>
|
||||
<rect x="2" y="9" width="1" height="1" fill="#101018"/>
|
||||
<rect x="3" y="9" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="10" y="9" width="1" height="1" fill="#101018"/>
|
||||
<rect x="11" y="9" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="2" y="10" width="1" height="1" fill="#101018"/>
|
||||
<rect x="3" y="10" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="10" y="10" width="1" height="1" fill="#101018"/>
|
||||
<rect x="11" y="10" width="2" height="1" fill="#f2c09a"/>
|
||||
<rect x="2" y="11" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="5" y="11" width="1" height="1" fill="#101018"/>
|
||||
<rect x="9" y="11" width="1" height="1" fill="#101018"/>
|
||||
<rect x="10" y="11" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="13" y="11" width="1" height="1" fill="#101018"/>
|
||||
<rect x="1" y="12" width="1" height="1" fill="#101018"/>
|
||||
<rect x="2" y="12" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="10" y="12" width="3" height="1" fill="#2e5fb7"/>
|
||||
<rect x="2" y="13" width="3" height="1" fill="#22406e"/>
|
||||
<rect x="10" y="13" width="3" height="1" fill="#22406e"/>
|
||||
<rect x="2" y="14" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="4" y="14" width="1" height="1" fill="#22406e"/>
|
||||
<rect x="10" y="14" width="2" height="1" fill="#22406e"/>
|
||||
<rect x="2" y="15" width="1" height="1" fill="#101018"/>
|
||||
<rect x="4" y="15" width="1" height="1" fill="#101018"/>
|
||||
<rect x="9" y="15" width="1" height="1" fill="#101018"/>
|
||||
<rect x="12" y="15" width="1" height="1" fill="#101018"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
16
pixel-sprite-maker/examples/walk_1.sprite
Normal file
16
pixel-sprite-maker/examples/walk_1.sprite
Normal file
@@ -0,0 +1,16 @@
|
||||
# Frame 1/4 of a tiny walking guy, 8x8. Legs together.
|
||||
sprite: walk_1
|
||||
palette:
|
||||
k = #101018 outline / hair
|
||||
s = #F2C09A skin
|
||||
b = #2E5FB7 shirt
|
||||
d = #22406E pants
|
||||
grid:
|
||||
..kkk...
|
||||
..kss...
|
||||
..kss...
|
||||
.kbbbk..
|
||||
..bbb...
|
||||
..ddd...
|
||||
..d.d...
|
||||
..k.k...
|
||||
16
pixel-sprite-maker/examples/walk_2.sprite
Normal file
16
pixel-sprite-maker/examples/walk_2.sprite
Normal file
@@ -0,0 +1,16 @@
|
||||
# Frame 2/4 - right leg forward.
|
||||
sprite: walk_2
|
||||
palette:
|
||||
k = #101018 outline / hair
|
||||
s = #F2C09A skin
|
||||
b = #2E5FB7 shirt
|
||||
d = #22406E pants
|
||||
grid:
|
||||
..kkk...
|
||||
..kss...
|
||||
..kss...
|
||||
.kbbbk..
|
||||
..bbb...
|
||||
..ddd...
|
||||
.d...d..
|
||||
.k...k..
|
||||
16
pixel-sprite-maker/examples/walk_3.sprite
Normal file
16
pixel-sprite-maker/examples/walk_3.sprite
Normal file
@@ -0,0 +1,16 @@
|
||||
# Frame 3/4 - legs together again (same pose as 1, arms swung).
|
||||
sprite: walk_3
|
||||
palette:
|
||||
k = #101018 outline / hair
|
||||
s = #F2C09A skin
|
||||
b = #2E5FB7 shirt
|
||||
d = #22406E pants
|
||||
grid:
|
||||
..kkk...
|
||||
..kss...
|
||||
..kss...
|
||||
..bbbk..
|
||||
.kbbb...
|
||||
..ddd...
|
||||
..d.d...
|
||||
..k.k...
|
||||
16
pixel-sprite-maker/examples/walk_4.sprite
Normal file
16
pixel-sprite-maker/examples/walk_4.sprite
Normal file
@@ -0,0 +1,16 @@
|
||||
# Frame 4/4 - left leg forward.
|
||||
sprite: walk_4
|
||||
palette:
|
||||
k = #101018 outline / hair
|
||||
s = #F2C09A skin
|
||||
b = #2E5FB7 shirt
|
||||
d = #22406E pants
|
||||
grid:
|
||||
..kkk...
|
||||
..kss...
|
||||
..kss...
|
||||
.kbbbk..
|
||||
..bbb...
|
||||
..ddd...
|
||||
..dd....
|
||||
.k..k...
|
||||
3
pixel-sprite-maker/go.mod
Normal file
3
pixel-sprite-maker/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module gitea.brasse-pc.eu/brasse/agent-tools/pixel-sprite-maker
|
||||
|
||||
go 1.24
|
||||
313
pixel-sprite-maker/main.go
Normal file
313
pixel-sprite-maker/main.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// spritec turns agent-friendly .sprite text files into PNG/JPG/SVG pixel
|
||||
// art, and combines several sprites into sprite sheets / animation strips.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/agent-tools/pixel-sprite-maker/sprite"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const usage = `spritec - pixel sprite maker for agents
|
||||
|
||||
Usage:
|
||||
spritec render <file.sprite> [flags] render one sprite to png/jpg/svg
|
||||
spritec sheet <a.sprite> <b.sprite> ... [flags]
|
||||
combine sprites into one sheet image
|
||||
spritec info <file.sprite> validate a file and print its stats
|
||||
spritec preview <file.sprite> draw the sprite in the terminal
|
||||
spritec version print version
|
||||
|
||||
Render flags:
|
||||
-o <path> output file; its extension picks the format (.png .jpg .svg)
|
||||
--format <f> png | jpg | svg (default: from -o, else png)
|
||||
--scale <n> integer upscale factor, default 1
|
||||
--bg <color> jpg background color (jpg has no alpha), default #FFFFFF
|
||||
--quality <n> jpg quality 1-100, default 90
|
||||
|
||||
Sheet flags (in addition to the render flags):
|
||||
-o <name> output base name; layout is appended automatically
|
||||
--cols <n> number of columns, row-major order (default: one row)
|
||||
|
||||
Sheet output naming:
|
||||
<name>_<cellW>x<cellH>_<cols>x<rows>.<ext>
|
||||
e.g. walk_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows,
|
||||
frames are read left-to-right then top-to-bottom (row-major).
|
||||
|
||||
The .sprite format:
|
||||
# comment
|
||||
sprite: coin optional name
|
||||
palette:
|
||||
. = none '.' is transparent by default
|
||||
k = #000000 hex, rgb(...), CSS names ('gold') and 'none' work
|
||||
y = gold anything after the color is a comment
|
||||
grid:
|
||||
..kk..
|
||||
.kyyk.
|
||||
.kyyk.
|
||||
..kk..
|
||||
|
||||
Each grid character is one pixel; every row must be the same width.
|
||||
Max sprite size: 256x256. Sheets may be bigger, single frames may not.
|
||||
`
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Print(usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "render":
|
||||
cmdRender(os.Args[2:])
|
||||
case "sheet":
|
||||
cmdSheet(os.Args[2:])
|
||||
case "info":
|
||||
cmdInfo(os.Args[2:])
|
||||
case "preview":
|
||||
cmdPreview(os.Args[2:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("spritec", version)
|
||||
case "help", "--help", "-h":
|
||||
fmt.Print(usage)
|
||||
default:
|
||||
die("unknown command %q — run 'spritec help'", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
// parseInterspersed lets flags appear before or after positional args
|
||||
// (stdlib flag stops at the first positional otherwise).
|
||||
func parseInterspersed(fs *flag.FlagSet, args []string) {
|
||||
var flags, pos []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if len(a) > 1 && a[0] == '-' {
|
||||
flags = append(flags, a)
|
||||
name := strings.TrimLeft(a, "-")
|
||||
if !strings.Contains(name, "=") {
|
||||
f := fs.Lookup(name)
|
||||
isBool := false
|
||||
if f != nil {
|
||||
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
|
||||
isBool = true
|
||||
}
|
||||
}
|
||||
if !isBool && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pos = append(pos, a)
|
||||
}
|
||||
}
|
||||
fs.Parse(append(flags, pos...))
|
||||
}
|
||||
|
||||
type renderFlags struct {
|
||||
out string
|
||||
format string
|
||||
scale int
|
||||
bg string
|
||||
quality int
|
||||
}
|
||||
|
||||
func addRenderFlags(fs *flag.FlagSet) *renderFlags {
|
||||
rf := &renderFlags{}
|
||||
fs.StringVar(&rf.out, "o", "", "output path")
|
||||
fs.StringVar(&rf.format, "format", "", "png|jpg|svg")
|
||||
fs.IntVar(&rf.scale, "scale", 1, "integer upscale factor")
|
||||
fs.StringVar(&rf.bg, "bg", "#FFFFFF", "jpg background color")
|
||||
fs.IntVar(&rf.quality, "quality", 90, "jpg quality 1-100")
|
||||
return rf
|
||||
}
|
||||
|
||||
// resolveFormat picks the output format from --format or the -o extension.
|
||||
func (rf *renderFlags) resolveFormat() (string, error) {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(rf.out), "."))
|
||||
if ext == "jpeg" {
|
||||
ext = "jpg"
|
||||
}
|
||||
f := strings.ToLower(rf.format)
|
||||
switch {
|
||||
case f == "" && ext == "":
|
||||
return "png", nil
|
||||
case f == "":
|
||||
f = ext
|
||||
case ext != "" && ext != f:
|
||||
return "", fmt.Errorf("--format %s conflicts with output extension .%s", f, ext)
|
||||
}
|
||||
switch f {
|
||||
case "png", "jpg", "svg":
|
||||
return f, nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported format %q (png, jpg or svg)", f)
|
||||
}
|
||||
|
||||
func writeGrid(path, format string, g sprite.PixelGrid, rf *renderFlags) error {
|
||||
bg, err := sprite.ParseColor(rf.bg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--bg: %w", err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
err = sprite.WritePNG(f, g, rf.scale)
|
||||
case "jpg":
|
||||
err = sprite.WriteJPG(f, g, rf.scale, bg, rf.quality)
|
||||
case "svg":
|
||||
err = sprite.WriteSVG(f, g, rf.scale)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func cmdRender(args []string) {
|
||||
fs := flag.NewFlagSet("render", flag.ExitOnError)
|
||||
rf := addRenderFlags(fs)
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() != 1 {
|
||||
die("render takes exactly one .sprite file")
|
||||
}
|
||||
in := fs.Arg(0)
|
||||
s, err := sprite.ParseFile(in)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
format, err := rf.resolveFormat()
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
out := rf.out
|
||||
if out == "" {
|
||||
out = strings.TrimSuffix(in, filepath.Ext(in)) + "." + format
|
||||
}
|
||||
if err := writeGrid(out, format, s, rf); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
w, h := s.Bounds()
|
||||
fmt.Printf("%s (%dx%d px, scale %d -> %dx%d)\n", out, w, h, rf.scale, w*rf.scale, h*rf.scale)
|
||||
}
|
||||
|
||||
func cmdSheet(args []string) {
|
||||
fs := flag.NewFlagSet("sheet", flag.ExitOnError)
|
||||
rf := addRenderFlags(fs)
|
||||
cols := fs.Int("cols", 0, "columns in the sheet (default: all sprites in one row)")
|
||||
parseInterspersed(fs, args)
|
||||
if fs.NArg() < 1 {
|
||||
die("sheet needs at least one .sprite file")
|
||||
}
|
||||
var sprites []*sprite.Sprite
|
||||
for _, path := range fs.Args() {
|
||||
s, err := sprite.ParseFile(path)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
sprites = append(sprites, s)
|
||||
}
|
||||
sh, err := sprite.NewSheet(sprites, *cols)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
format, err := rf.resolveFormat()
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
base := rf.out
|
||||
if base == "" {
|
||||
base = "sheet"
|
||||
}
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
out := sh.FileBase(base) + "." + format
|
||||
if err := writeGrid(out, format, sh, rf); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
w, h := sh.Bounds()
|
||||
fmt.Printf("%s (%d frames of %dx%d px in %dx%d grid, total %dx%d px)\n",
|
||||
out, len(sprites), sh.CellW, sh.CellH, sh.Cols, sh.Rows, w*rf.scale, h*rf.scale)
|
||||
}
|
||||
|
||||
func cmdInfo(args []string) {
|
||||
if len(args) != 1 {
|
||||
die("info takes exactly one .sprite file")
|
||||
}
|
||||
s, err := sprite.ParseFile(args[0])
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Printf("sprite: %s\n", s.Name)
|
||||
fmt.Printf("size: %dx%d px\n", s.W, s.H)
|
||||
fmt.Printf("palette: %d colors\n", len(s.Keys))
|
||||
usage := s.UsageCount()
|
||||
for _, k := range s.Keys {
|
||||
note := ""
|
||||
if usage[k] == 0 {
|
||||
note = " (unused)"
|
||||
}
|
||||
fmt.Printf(" %s = %-9s %5d px%s\n", string(k), sprite.FormatColor(s.Palette[k]), usage[k], note)
|
||||
}
|
||||
if _, defined := usage['.']; defined && !contains(s.Keys, '.') {
|
||||
fmt.Printf(" . = none %5d px (implicit transparent)\n", usage['.'])
|
||||
}
|
||||
fmt.Println("valid: yes")
|
||||
}
|
||||
|
||||
func contains(keys []rune, k rune) bool {
|
||||
for _, x := range keys {
|
||||
if x == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cmdPreview draws the sprite with truecolor half-blocks, two pixel rows
|
||||
// per terminal line. Transparent pixels show the terminal background.
|
||||
func cmdPreview(args []string) {
|
||||
if len(args) != 1 {
|
||||
die("preview takes exactly one .sprite file")
|
||||
}
|
||||
s, err := sprite.ParseFile(args[0])
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
const reset = "\x1b[0m"
|
||||
var b strings.Builder
|
||||
for y := 0; y < s.H; y += 2 {
|
||||
for x := 0; x < s.W; x++ {
|
||||
top, bot := s.At(x, y), sprite.Transparent
|
||||
if y+1 < s.H {
|
||||
bot = s.At(x, y+1)
|
||||
}
|
||||
topOn, botOn := top.A >= 128, bot.A >= 128
|
||||
switch {
|
||||
case topOn && botOn:
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s", top.R, top.G, top.B, bot.R, bot.G, bot.B, reset)
|
||||
case topOn:
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▀%s", top.R, top.G, top.B, reset)
|
||||
case botOn:
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▄%s", bot.R, bot.G, bot.B, reset)
|
||||
default:
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
fmt.Printf("%s %dx%d px\n%s", s.Name, s.W, s.H, b.String())
|
||||
}
|
||||
|
||||
func die(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "spritec: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
270
pixel-sprite-maker/sprite/color.go
Normal file
270
pixel-sprite-maker/sprite/color.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Transparent is the color used for pixels that should not be drawn.
|
||||
var Transparent = color.NRGBA{0, 0, 0, 0}
|
||||
|
||||
// ParseColor accepts:
|
||||
//
|
||||
// none | transparent | - -> fully transparent
|
||||
// #RGB #RGBA #RRGGBB #RRGGBBAA -> hex
|
||||
// rgb(r,g,b) rgba(r,g,b,a) -> 0-255 channels, alpha 0-255 or 0.0-1.0
|
||||
// CSS named colors -> "red", "rebeccapurple", ...
|
||||
func ParseColor(s string) (color.NRGBA, error) {
|
||||
t := strings.ToLower(strings.TrimSpace(s))
|
||||
if t == "" {
|
||||
return Transparent, fmt.Errorf("empty color value")
|
||||
}
|
||||
switch t {
|
||||
case "none", "transparent", "-":
|
||||
return Transparent, nil
|
||||
}
|
||||
if strings.HasPrefix(t, "#") {
|
||||
return parseHex(t)
|
||||
}
|
||||
if strings.HasPrefix(t, "rgb(") || strings.HasPrefix(t, "rgba(") {
|
||||
return parseRGBFunc(t)
|
||||
}
|
||||
if c, ok := cssColors[t]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return Transparent, fmt.Errorf("unknown color %q (use #RRGGBB, rgb(r,g,b), a CSS color name, or 'none')", s)
|
||||
}
|
||||
|
||||
func parseHex(t string) (color.NRGBA, error) {
|
||||
h := t[1:]
|
||||
var r, g, b, a uint64
|
||||
var err error
|
||||
dup := func(s string) (uint64, error) {
|
||||
v, err := strconv.ParseUint(s, 16, 8)
|
||||
return v*16 + v, err
|
||||
}
|
||||
a = 255
|
||||
switch len(h) {
|
||||
case 3, 4:
|
||||
if r, err = dup(h[0:1]); err == nil {
|
||||
if g, err = dup(h[1:2]); err == nil {
|
||||
b, err = dup(h[2:3])
|
||||
}
|
||||
}
|
||||
if err == nil && len(h) == 4 {
|
||||
a, err = dup(h[3:4])
|
||||
}
|
||||
case 6, 8:
|
||||
if r, err = strconv.ParseUint(h[0:2], 16, 8); err == nil {
|
||||
if g, err = strconv.ParseUint(h[2:4], 16, 8); err == nil {
|
||||
b, err = strconv.ParseUint(h[4:6], 16, 8)
|
||||
}
|
||||
}
|
||||
if err == nil && len(h) == 8 {
|
||||
a, err = strconv.ParseUint(h[6:8], 16, 8)
|
||||
}
|
||||
default:
|
||||
return Transparent, fmt.Errorf("hex color %q must be #RGB, #RGBA, #RRGGBB or #RRGGBBAA", t)
|
||||
}
|
||||
if err != nil {
|
||||
return Transparent, fmt.Errorf("invalid hex color %q", t)
|
||||
}
|
||||
return color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)}, nil
|
||||
}
|
||||
|
||||
func parseRGBFunc(t string) (color.NRGBA, error) {
|
||||
open := strings.Index(t, "(")
|
||||
close := strings.Index(t, ")")
|
||||
if close < open {
|
||||
return Transparent, fmt.Errorf("invalid color %q: missing ')'", t)
|
||||
}
|
||||
parts := strings.Split(t[open+1:close], ",")
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return Transparent, fmt.Errorf("invalid color %q: want rgb(r,g,b) or rgba(r,g,b,a)", t)
|
||||
}
|
||||
var ch [4]uint8
|
||||
ch[3] = 255
|
||||
for i, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if i == 3 && strings.Contains(p, ".") {
|
||||
// CSS-style fractional alpha 0.0 - 1.0
|
||||
f, err := strconv.ParseFloat(p, 64)
|
||||
if err != nil || f < 0 || f > 1 {
|
||||
return Transparent, fmt.Errorf("invalid alpha %q in %q (want 0.0-1.0 or 0-255)", p, t)
|
||||
}
|
||||
ch[3] = uint8(f*255 + 0.5)
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(p, 10, 8)
|
||||
if err != nil {
|
||||
return Transparent, fmt.Errorf("invalid channel %q in %q (want 0-255)", p, t)
|
||||
}
|
||||
ch[i] = uint8(v)
|
||||
}
|
||||
return color.NRGBA{ch[0], ch[1], ch[2], ch[3]}, nil
|
||||
}
|
||||
|
||||
// FormatColor renders a color the way it should appear in a .sprite file.
|
||||
func FormatColor(c color.NRGBA) string {
|
||||
if c.A == 0 {
|
||||
return "none"
|
||||
}
|
||||
if c.A == 255 {
|
||||
return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B)
|
||||
}
|
||||
return fmt.Sprintf("#%02X%02X%02X%02X", c.R, c.G, c.B, c.A)
|
||||
}
|
||||
|
||||
// cssColors is the full CSS Color Module Level 4 named-color list.
|
||||
var cssColors = map[string]color.NRGBA{
|
||||
"aliceblue": {240, 248, 255, 255},
|
||||
"antiquewhite": {250, 235, 215, 255},
|
||||
"aqua": {0, 255, 255, 255},
|
||||
"aquamarine": {127, 255, 212, 255},
|
||||
"azure": {240, 255, 255, 255},
|
||||
"beige": {245, 245, 220, 255},
|
||||
"bisque": {255, 228, 196, 255},
|
||||
"black": {0, 0, 0, 255},
|
||||
"blanchedalmond": {255, 235, 205, 255},
|
||||
"blue": {0, 0, 255, 255},
|
||||
"blueviolet": {138, 43, 226, 255},
|
||||
"brown": {165, 42, 42, 255},
|
||||
"burlywood": {222, 184, 135, 255},
|
||||
"cadetblue": {95, 158, 160, 255},
|
||||
"chartreuse": {127, 255, 0, 255},
|
||||
"chocolate": {210, 105, 30, 255},
|
||||
"coral": {255, 127, 80, 255},
|
||||
"cornflowerblue": {100, 149, 237, 255},
|
||||
"cornsilk": {255, 248, 220, 255},
|
||||
"crimson": {220, 20, 60, 255},
|
||||
"cyan": {0, 255, 255, 255},
|
||||
"darkblue": {0, 0, 139, 255},
|
||||
"darkcyan": {0, 139, 139, 255},
|
||||
"darkgoldenrod": {184, 134, 11, 255},
|
||||
"darkgray": {169, 169, 169, 255},
|
||||
"darkgreen": {0, 100, 0, 255},
|
||||
"darkgrey": {169, 169, 169, 255},
|
||||
"darkkhaki": {189, 183, 107, 255},
|
||||
"darkmagenta": {139, 0, 139, 255},
|
||||
"darkolivegreen": {85, 107, 47, 255},
|
||||
"darkorange": {255, 140, 0, 255},
|
||||
"darkorchid": {153, 50, 204, 255},
|
||||
"darkred": {139, 0, 0, 255},
|
||||
"darksalmon": {233, 150, 122, 255},
|
||||
"darkseagreen": {143, 188, 143, 255},
|
||||
"darkslateblue": {72, 61, 139, 255},
|
||||
"darkslategray": {47, 79, 79, 255},
|
||||
"darkslategrey": {47, 79, 79, 255},
|
||||
"darkturquoise": {0, 206, 209, 255},
|
||||
"darkviolet": {148, 0, 211, 255},
|
||||
"deeppink": {255, 20, 147, 255},
|
||||
"deepskyblue": {0, 191, 255, 255},
|
||||
"dimgray": {105, 105, 105, 255},
|
||||
"dimgrey": {105, 105, 105, 255},
|
||||
"dodgerblue": {30, 144, 255, 255},
|
||||
"firebrick": {178, 34, 34, 255},
|
||||
"floralwhite": {255, 250, 240, 255},
|
||||
"forestgreen": {34, 139, 34, 255},
|
||||
"fuchsia": {255, 0, 255, 255},
|
||||
"gainsboro": {220, 220, 220, 255},
|
||||
"ghostwhite": {248, 248, 255, 255},
|
||||
"gold": {255, 215, 0, 255},
|
||||
"goldenrod": {218, 165, 32, 255},
|
||||
"gray": {128, 128, 128, 255},
|
||||
"green": {0, 128, 0, 255},
|
||||
"greenyellow": {173, 255, 47, 255},
|
||||
"grey": {128, 128, 128, 255},
|
||||
"honeydew": {240, 255, 240, 255},
|
||||
"hotpink": {255, 105, 180, 255},
|
||||
"indianred": {205, 92, 92, 255},
|
||||
"indigo": {75, 0, 130, 255},
|
||||
"ivory": {255, 255, 240, 255},
|
||||
"khaki": {240, 230, 140, 255},
|
||||
"lavender": {230, 230, 250, 255},
|
||||
"lavenderblush": {255, 240, 245, 255},
|
||||
"lawngreen": {124, 252, 0, 255},
|
||||
"lemonchiffon": {255, 250, 205, 255},
|
||||
"lightblue": {173, 216, 230, 255},
|
||||
"lightcoral": {240, 128, 128, 255},
|
||||
"lightcyan": {224, 255, 255, 255},
|
||||
"lightgoldenrodyellow": {250, 250, 210, 255},
|
||||
"lightgray": {211, 211, 211, 255},
|
||||
"lightgreen": {144, 238, 144, 255},
|
||||
"lightgrey": {211, 211, 211, 255},
|
||||
"lightpink": {255, 182, 193, 255},
|
||||
"lightsalmon": {255, 160, 122, 255},
|
||||
"lightseagreen": {32, 178, 170, 255},
|
||||
"lightskyblue": {135, 206, 250, 255},
|
||||
"lightslategray": {119, 136, 153, 255},
|
||||
"lightslategrey": {119, 136, 153, 255},
|
||||
"lightsteelblue": {176, 196, 222, 255},
|
||||
"lightyellow": {255, 255, 224, 255},
|
||||
"lime": {0, 255, 0, 255},
|
||||
"limegreen": {50, 205, 50, 255},
|
||||
"linen": {250, 240, 230, 255},
|
||||
"magenta": {255, 0, 255, 255},
|
||||
"maroon": {128, 0, 0, 255},
|
||||
"mediumaquamarine": {102, 205, 170, 255},
|
||||
"mediumblue": {0, 0, 205, 255},
|
||||
"mediumorchid": {186, 85, 211, 255},
|
||||
"mediumpurple": {147, 112, 219, 255},
|
||||
"mediumseagreen": {60, 179, 113, 255},
|
||||
"mediumslateblue": {123, 104, 238, 255},
|
||||
"mediumspringgreen": {0, 250, 154, 255},
|
||||
"mediumturquoise": {72, 209, 204, 255},
|
||||
"mediumvioletred": {199, 21, 133, 255},
|
||||
"midnightblue": {25, 25, 112, 255},
|
||||
"mintcream": {245, 255, 250, 255},
|
||||
"mistyrose": {255, 228, 225, 255},
|
||||
"moccasin": {255, 228, 181, 255},
|
||||
"navajowhite": {255, 222, 173, 255},
|
||||
"navy": {0, 0, 128, 255},
|
||||
"oldlace": {253, 245, 230, 255},
|
||||
"olive": {128, 128, 0, 255},
|
||||
"olivedrab": {107, 142, 35, 255},
|
||||
"orange": {255, 165, 0, 255},
|
||||
"orangered": {255, 69, 0, 255},
|
||||
"orchid": {218, 112, 214, 255},
|
||||
"palegoldenrod": {238, 232, 170, 255},
|
||||
"palegreen": {152, 251, 152, 255},
|
||||
"paleturquoise": {175, 238, 238, 255},
|
||||
"palevioletred": {219, 112, 147, 255},
|
||||
"papayawhip": {255, 239, 213, 255},
|
||||
"peachpuff": {255, 218, 185, 255},
|
||||
"peru": {205, 133, 63, 255},
|
||||
"pink": {255, 192, 203, 255},
|
||||
"plum": {221, 160, 221, 255},
|
||||
"powderblue": {176, 224, 230, 255},
|
||||
"purple": {128, 0, 128, 255},
|
||||
"rebeccapurple": {102, 51, 153, 255},
|
||||
"red": {255, 0, 0, 255},
|
||||
"rosybrown": {188, 143, 143, 255},
|
||||
"royalblue": {65, 105, 225, 255},
|
||||
"saddlebrown": {139, 69, 19, 255},
|
||||
"salmon": {250, 128, 114, 255},
|
||||
"sandybrown": {244, 164, 96, 255},
|
||||
"seagreen": {46, 139, 87, 255},
|
||||
"seashell": {255, 245, 238, 255},
|
||||
"sienna": {160, 82, 45, 255},
|
||||
"silver": {192, 192, 192, 255},
|
||||
"skyblue": {135, 206, 235, 255},
|
||||
"slateblue": {106, 90, 205, 255},
|
||||
"slategray": {112, 128, 144, 255},
|
||||
"slategrey": {112, 128, 144, 255},
|
||||
"snow": {255, 250, 250, 255},
|
||||
"springgreen": {0, 255, 127, 255},
|
||||
"steelblue": {70, 130, 180, 255},
|
||||
"tan": {210, 180, 140, 255},
|
||||
"teal": {0, 128, 128, 255},
|
||||
"thistle": {216, 191, 216, 255},
|
||||
"tomato": {255, 99, 71, 255},
|
||||
"turquoise": {64, 224, 208, 255},
|
||||
"violet": {238, 130, 238, 255},
|
||||
"wheat": {245, 222, 179, 255},
|
||||
"white": {255, 255, 255, 255},
|
||||
"whitesmoke": {245, 245, 245, 255},
|
||||
"yellow": {255, 255, 0, 255},
|
||||
"yellowgreen": {154, 205, 50, 255},
|
||||
}
|
||||
186
pixel-sprite-maker/sprite/parse.go
Normal file
186
pixel-sprite-maker/sprite/parse.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxSize is the maximum width/height of a single sprite in pixels.
|
||||
const MaxSize = 256
|
||||
|
||||
// Sprite is a parsed .sprite file: a palette of single-rune keys and a
|
||||
// rectangular grid of those keys.
|
||||
type Sprite struct {
|
||||
Name string
|
||||
W, H int
|
||||
Palette map[rune]color.NRGBA
|
||||
Keys []rune // palette keys in file order
|
||||
Rows [][]rune // H rows of exactly W palette keys
|
||||
}
|
||||
|
||||
// At returns the color of pixel (x, y). Out-of-range pixels are transparent.
|
||||
func (s *Sprite) At(x, y int) color.NRGBA {
|
||||
if x < 0 || y < 0 || x >= s.W || y >= s.H {
|
||||
return Transparent
|
||||
}
|
||||
return s.Palette[s.Rows[y][x]]
|
||||
}
|
||||
|
||||
// Bounds returns the sprite size in pixels.
|
||||
func (s *Sprite) Bounds() (w, h int) { return s.W, s.H }
|
||||
|
||||
// ParseFile reads a .sprite file from disk. The sprite name defaults to the
|
||||
// file name without extension when the file has no "sprite:" line.
|
||||
func ParseFile(path string) (*Sprite, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s, err := Parse(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if s.Name == "" {
|
||||
s.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Parse reads the .sprite text format:
|
||||
//
|
||||
// # comment lines start with '#'
|
||||
// sprite: coin (optional name)
|
||||
// palette:
|
||||
// . = none (key '.' is transparent by default)
|
||||
// k = #000000 text after the color is ignored
|
||||
// y = gold
|
||||
// grid:
|
||||
// ..kk..
|
||||
// .kyyk.
|
||||
//
|
||||
// Palette keys are exactly one character and may not be '#', '=', ':' or
|
||||
// whitespace. Every grid row must be the same width; max size is 256x256.
|
||||
func Parse(r io.Reader) (*Sprite, error) {
|
||||
s := &Sprite{Palette: map[rune]color.NRGBA{}}
|
||||
const (
|
||||
secNone = iota
|
||||
secPalette
|
||||
secGrid
|
||||
)
|
||||
section := secNone
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // comment or blank ('#' is not a legal palette key)
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case lower == "palette:":
|
||||
section = secPalette
|
||||
continue
|
||||
case lower == "grid:":
|
||||
section = secGrid
|
||||
continue
|
||||
case strings.HasPrefix(lower, "sprite:"):
|
||||
s.Name = strings.TrimSpace(line[len("sprite:"):])
|
||||
continue
|
||||
}
|
||||
switch section {
|
||||
case secPalette:
|
||||
if err := s.parsePaletteLine(line, lineNo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case secGrid:
|
||||
row := []rune(line)
|
||||
s.Rows = append(s.Rows, row)
|
||||
default:
|
||||
return nil, fmt.Errorf("line %d: unexpected %q before a 'palette:' or 'grid:' section", lineNo, line)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, s.validate()
|
||||
}
|
||||
|
||||
func (s *Sprite) parsePaletteLine(line string, lineNo int) error {
|
||||
eq := strings.Index(line, "=")
|
||||
if eq < 0 {
|
||||
return fmt.Errorf("line %d: palette entry %q must look like '<key> = <color>'", lineNo, line)
|
||||
}
|
||||
keyPart := []rune(strings.TrimSpace(line[:eq]))
|
||||
if len(keyPart) != 1 {
|
||||
return fmt.Errorf("line %d: palette key %q must be exactly one character", lineNo, strings.TrimSpace(line[:eq]))
|
||||
}
|
||||
key := keyPart[0]
|
||||
if key == '#' || key == '=' || key == ':' {
|
||||
return fmt.Errorf("line %d: %q is not allowed as a palette key", lineNo, key)
|
||||
}
|
||||
if _, dup := s.Palette[key]; dup {
|
||||
return fmt.Errorf("line %d: palette key %q defined twice", lineNo, key)
|
||||
}
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
// The color is the first token; anything after it is a free-text comment.
|
||||
token := val
|
||||
if strings.HasPrefix(strings.ToLower(val), "rgb") {
|
||||
if close := strings.Index(val, ")"); close >= 0 {
|
||||
token = val[:close+1]
|
||||
}
|
||||
} else if i := strings.IndexAny(val, " \t"); i >= 0 {
|
||||
token = val[:i]
|
||||
}
|
||||
c, err := ParseColor(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("line %d: %w", lineNo, err)
|
||||
}
|
||||
s.Palette[key] = c
|
||||
s.Keys = append(s.Keys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sprite) validate() error {
|
||||
if len(s.Rows) == 0 {
|
||||
return fmt.Errorf("no 'grid:' section with at least one row found")
|
||||
}
|
||||
// '.' is transparent unless the file overrides it.
|
||||
if _, ok := s.Palette['.']; !ok {
|
||||
s.Palette['.'] = Transparent
|
||||
}
|
||||
s.H = len(s.Rows)
|
||||
s.W = len(s.Rows[0])
|
||||
if s.W > MaxSize || s.H > MaxSize {
|
||||
return fmt.Errorf("sprite is %dx%d pixels; the maximum is %dx%d", s.W, s.H, MaxSize, MaxSize)
|
||||
}
|
||||
for y, row := range s.Rows {
|
||||
if len(row) != s.W {
|
||||
return fmt.Errorf("grid row %d is %d pixels wide, expected %d (all rows must match row 1)", y+1, len(row), s.W)
|
||||
}
|
||||
for x, key := range row {
|
||||
if _, ok := s.Palette[key]; !ok {
|
||||
return fmt.Errorf("grid row %d, column %d: %q is not defined in the palette", y+1, x+1, string(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsageCount returns how many grid pixels use each palette key.
|
||||
func (s *Sprite) UsageCount() map[rune]int {
|
||||
n := map[rune]int{}
|
||||
for _, row := range s.Rows {
|
||||
for _, k := range row {
|
||||
n[k]++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
75
pixel-sprite-maker/sprite/render.go
Normal file
75
pixel-sprite-maker/sprite/render.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
// PixelGrid is anything that can be rendered pixel by pixel: a single
|
||||
// Sprite or a composed Sheet.
|
||||
type PixelGrid interface {
|
||||
Bounds() (w, h int)
|
||||
At(x, y int) color.NRGBA
|
||||
}
|
||||
|
||||
// Image renders a PixelGrid to an NRGBA image, scaled up by the integer
|
||||
// factor scale (nearest neighbour, keeps pixels crisp).
|
||||
func Image(g PixelGrid, scale int) *image.NRGBA {
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
w, h := g.Bounds()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w*scale, h*scale))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := g.At(x, y)
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.SetNRGBA(x*scale+dx, y*scale+dy, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// WritePNG encodes g as PNG with transparency preserved.
|
||||
func WritePNG(w io.Writer, g PixelGrid, scale int) error {
|
||||
return png.Encode(w, Image(g, scale))
|
||||
}
|
||||
|
||||
// WriteJPG encodes g as JPEG. JPEG has no alpha channel, so transparent
|
||||
// pixels are composited over bg first.
|
||||
func WriteJPG(w io.Writer, g PixelGrid, scale int, bg color.NRGBA, quality int) error {
|
||||
if quality < 1 || quality > 100 {
|
||||
return fmt.Errorf("jpg quality %d out of range 1-100", quality)
|
||||
}
|
||||
src := Image(g, scale)
|
||||
b := src.Bounds()
|
||||
flat := image.NewRGBA(b)
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
flat.Set(x, y, blendOver(src.NRGBAAt(x, y), bg))
|
||||
}
|
||||
}
|
||||
return jpeg.Encode(w, flat, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
// blendOver composites src over an opaque background color.
|
||||
func blendOver(src, bg color.NRGBA) color.NRGBA {
|
||||
if src.A == 255 {
|
||||
return src
|
||||
}
|
||||
a := uint32(src.A)
|
||||
inv := 255 - a
|
||||
return color.NRGBA{
|
||||
R: uint8((uint32(src.R)*a + uint32(bg.R)*inv) / 255),
|
||||
G: uint8((uint32(src.G)*a + uint32(bg.G)*inv) / 255),
|
||||
B: uint8((uint32(src.B)*a + uint32(bg.B)*inv) / 255),
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
58
pixel-sprite-maker/sprite/sheet.go
Normal file
58
pixel-sprite-maker/sprite/sheet.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// Sheet lays out several equally sized sprites in a row-major grid:
|
||||
// index 0 is top-left, then left-to-right, then next row. A 1D strip is
|
||||
// simply a sheet with one row (or one column).
|
||||
type Sheet struct {
|
||||
Sprites []*Sprite
|
||||
Cols, Rows int
|
||||
CellW, CellH int
|
||||
}
|
||||
|
||||
// NewSheet composes sprites into a sheet with the given number of columns.
|
||||
// cols <= 0 puts everything in a single row. Every sprite must have the
|
||||
// same dimensions so the sheet can be indexed cell by cell.
|
||||
func NewSheet(sprites []*Sprite, cols int) (*Sheet, error) {
|
||||
if len(sprites) == 0 {
|
||||
return nil, fmt.Errorf("a sheet needs at least one sprite")
|
||||
}
|
||||
w, h := sprites[0].Bounds()
|
||||
for _, sp := range sprites[1:] {
|
||||
sw, sh := sp.Bounds()
|
||||
if sw != w || sh != h {
|
||||
return nil, fmt.Errorf("sprite %q is %dx%d but %q is %dx%d — all sprites in a sheet must be the same size",
|
||||
sp.Name, sw, sh, sprites[0].Name, w, h)
|
||||
}
|
||||
}
|
||||
if cols <= 0 || cols > len(sprites) {
|
||||
cols = len(sprites)
|
||||
}
|
||||
rows := (len(sprites) + cols - 1) / cols
|
||||
return &Sheet{Sprites: sprites, Cols: cols, Rows: rows, CellW: w, CellH: h}, nil
|
||||
}
|
||||
|
||||
// Bounds returns the total sheet size in pixels.
|
||||
func (sh *Sheet) Bounds() (w, h int) { return sh.Cols * sh.CellW, sh.Rows * sh.CellH }
|
||||
|
||||
// At returns the pixel at (x, y). Cells past the last sprite (when the
|
||||
// sprite count doesn't fill the grid) are transparent.
|
||||
func (sh *Sheet) At(x, y int) color.NRGBA {
|
||||
col, row := x/sh.CellW, y/sh.CellH
|
||||
idx := row*sh.Cols + col
|
||||
if idx >= len(sh.Sprites) {
|
||||
return Transparent
|
||||
}
|
||||
return sh.Sprites[idx].At(x%sh.CellW, y%sh.CellH)
|
||||
}
|
||||
|
||||
// FileBase appends the layout to an output name so the file itself
|
||||
// documents cell size and orientation: <base>_<cellW>x<cellH>_<cols>x<rows>
|
||||
// e.g. walk_16x16_4x2 = 16x16 cells, 4 columns, 2 rows, read row by row.
|
||||
func (sh *Sheet) FileBase(base string) string {
|
||||
return fmt.Sprintf("%s_%dx%d_%dx%d", base, sh.CellW, sh.CellH, sh.Cols, sh.Rows)
|
||||
}
|
||||
207
pixel-sprite-maker/sprite/sprite_test.go
Normal file
207
pixel-sprite-maker/sprite/sprite_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"image/color"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const coin = `
|
||||
# a tiny coin
|
||||
sprite: coin
|
||||
palette:
|
||||
k = #000000
|
||||
y = gold
|
||||
Y = #FFF3A0 highlight
|
||||
grid:
|
||||
.kk.
|
||||
kyYk
|
||||
kyyk
|
||||
.kk.
|
||||
`
|
||||
|
||||
func parseOK(t *testing.T, src string) *Sprite {
|
||||
t.Helper()
|
||||
s, err := Parse(strings.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestParseBasics(t *testing.T) {
|
||||
s := parseOK(t, coin)
|
||||
if s.Name != "coin" {
|
||||
t.Errorf("name = %q, want coin", s.Name)
|
||||
}
|
||||
if s.W != 4 || s.H != 4 {
|
||||
t.Errorf("size = %dx%d, want 4x4", s.W, s.H)
|
||||
}
|
||||
if got := s.At(0, 0); got.A != 0 {
|
||||
t.Errorf("(0,0) should be transparent via the implicit '.', got %v", got)
|
||||
}
|
||||
if got := s.At(1, 1); got != (color.NRGBA{255, 215, 0, 255}) {
|
||||
t.Errorf("(1,1) = %v, want gold", got)
|
||||
}
|
||||
if got := s.At(2, 1); got != (color.NRGBA{255, 243, 160, 255}) {
|
||||
t.Errorf("(2,1) = %v, want #FFF3A0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ragged rows": "palette:\n a = red\ngrid:\naa\naaa\n",
|
||||
"unknown key": "palette:\n a = red\ngrid:\nab\naa\n",
|
||||
"bad color": "palette:\n a = notacolor\ngrid:\na\n",
|
||||
"no grid": "palette:\n a = red\n",
|
||||
"dup key": "palette:\n a = red\n a = blue\ngrid:\na\n",
|
||||
"multirune key": "palette:\n ab = red\ngrid:\na\n",
|
||||
"colon key": "palette:\n : = red\ngrid:\n.\n",
|
||||
}
|
||||
for name, src := range cases {
|
||||
if _, err := Parse(strings.NewReader(src)); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSize(t *testing.T) {
|
||||
row := strings.Repeat("a", 257)
|
||||
src := "palette:\n a = red\ngrid:\n" + row + "\n"
|
||||
if _, err := Parse(strings.NewReader(src)); err == nil {
|
||||
t.Error("257 px wide sprite should be rejected")
|
||||
}
|
||||
ok := "palette:\n a = red\ngrid:\n" + strings.Repeat(strings.Repeat("a", 256)+"\n", 256)
|
||||
s := parseOK(t, ok)
|
||||
if s.W != 256 || s.H != 256 {
|
||||
t.Errorf("size = %dx%d, want 256x256", s.W, s.H)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColorForms(t *testing.T) {
|
||||
cases := map[string]color.NRGBA{
|
||||
"none": {0, 0, 0, 0},
|
||||
"-": {0, 0, 0, 0},
|
||||
"#F00": {255, 0, 0, 255},
|
||||
"#F00A": {255, 0, 0, 170},
|
||||
"#00FF00": {0, 255, 0, 255},
|
||||
"#00FF0080": {0, 255, 0, 128},
|
||||
"rgb(1,2,3)": {1, 2, 3, 255},
|
||||
"rgba(1,2,3,64)": {1, 2, 3, 64},
|
||||
"rgba(1, 2, 3, .5)": {1, 2, 3, 128},
|
||||
"RebeccaPurple": {102, 51, 153, 255},
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := ParseColor(in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseColor(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("ParseColor(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "#12345", "rgb(300,0,0)", "purpleish"} {
|
||||
if _, err := ParseColor(bad); err == nil {
|
||||
t.Errorf("ParseColor(%q): expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageAndScale(t *testing.T) {
|
||||
s := parseOK(t, coin)
|
||||
img := Image(s, 3)
|
||||
if b := img.Bounds(); b.Dx() != 12 || b.Dy() != 12 {
|
||||
t.Fatalf("scaled bounds = %v, want 12x12", b)
|
||||
}
|
||||
// pixel (1,1) is gold -> block at (3..5, 3..5)
|
||||
if got := img.NRGBAAt(4, 4); got != (color.NRGBA{255, 215, 0, 255}) {
|
||||
t.Errorf("scaled gold pixel = %v", got)
|
||||
}
|
||||
if got := img.NRGBAAt(0, 0); got.A != 0 {
|
||||
t.Errorf("corner should stay transparent, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGOutput(t *testing.T) {
|
||||
s := parseOK(t, coin)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteSVG(&buf, s, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `viewBox="0 0 4 4"`) || !strings.Contains(out, `width="40"`) {
|
||||
t.Errorf("svg header wrong:\n%s", out)
|
||||
}
|
||||
// row 2 (y=1) has runs k, yY?, no: k y Y k -> y and Y differ, so no merge.
|
||||
// row 3 (y=2) k yy k -> the two golds merge into one rect of width 2.
|
||||
if !strings.Contains(out, `<rect x="1" y="2" width="2" height="1" fill="#ffd700"/>`) {
|
||||
t.Errorf("expected RLE-merged gold rect:\n%s", out)
|
||||
}
|
||||
var doc struct{ XMLName xml.Name }
|
||||
if err := xml.Unmarshal(buf.Bytes(), &doc); err != nil {
|
||||
t.Errorf("svg is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetLayoutAndNaming(t *testing.T) {
|
||||
a := parseOK(t, coin)
|
||||
b := parseOK(t, coin)
|
||||
c := parseOK(t, coin)
|
||||
sh, err := NewSheet([]*Sprite{a, b, c}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sh.Cols != 2 || sh.Rows != 2 {
|
||||
t.Errorf("layout = %dx%d, want 2x2", sh.Cols, sh.Rows)
|
||||
}
|
||||
if w, h := sh.Bounds(); w != 8 || h != 8 {
|
||||
t.Errorf("bounds = %dx%d, want 8x8", w, h)
|
||||
}
|
||||
if got := sh.FileBase("walk"); got != "walk_4x4_2x2" {
|
||||
t.Errorf("FileBase = %q, want walk_4x4_2x2", got)
|
||||
}
|
||||
// cell (1,1) in frame 2 (top-right) is gold
|
||||
if got := sh.At(5, 1); got != (color.NRGBA{255, 215, 0, 255}) {
|
||||
t.Errorf("sheet pixel in frame 2 = %v, want gold", got)
|
||||
}
|
||||
// 4th cell (bottom-right) has no sprite -> transparent
|
||||
if got := sh.At(7, 7); got.A != 0 {
|
||||
t.Errorf("empty cell should be transparent, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSizeMismatch(t *testing.T) {
|
||||
a := parseOK(t, coin)
|
||||
b := parseOK(t, "palette:\n a = red\ngrid:\naa\n")
|
||||
if _, err := NewSheet([]*Sprite{a, b}, 0); err == nil {
|
||||
t.Error("mismatched sprite sizes should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDefaultSingleRow(t *testing.T) {
|
||||
a := parseOK(t, coin)
|
||||
sh, err := NewSheet([]*Sprite{a, a, a}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sh.Cols != 3 || sh.Rows != 1 {
|
||||
t.Errorf("default layout = %dx%d, want 3x1", sh.Cols, sh.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPGComposite(t *testing.T) {
|
||||
s := parseOK(t, coin)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteJPG(&buf, s, 1, color.NRGBA{255, 255, 255, 255}, 90); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("empty jpg output")
|
||||
}
|
||||
if err := WriteJPG(&buf, s, 1, Transparent, 150); err == nil {
|
||||
t.Error("quality 150 should be rejected")
|
||||
}
|
||||
}
|
||||
46
pixel-sprite-maker/sprite/svg.go
Normal file
46
pixel-sprite-maker/sprite/svg.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package sprite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// WriteSVG encodes g as an SVG where each horizontal run of same-colored
|
||||
// pixels becomes one <rect>. shape-rendering="crispEdges" keeps the pixel
|
||||
// look at any zoom. scale only affects the document width/height; the
|
||||
// viewBox stays in pixel units.
|
||||
func WriteSVG(w io.Writer, g PixelGrid, scale int) error {
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
gw, gh := g.Bounds()
|
||||
_, err := fmt.Fprintf(w,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" shape-rendering="crispEdges">`+"\n",
|
||||
gw*scale, gh*scale, gw, gh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for y := 0; y < gh; y++ {
|
||||
for x := 0; x < gw; {
|
||||
c := g.At(x, y)
|
||||
run := 1
|
||||
for x+run < gw && g.At(x+run, y) == c {
|
||||
run++
|
||||
}
|
||||
if c.A > 0 {
|
||||
opacity := ""
|
||||
if c.A < 255 {
|
||||
opacity = fmt.Sprintf(` fill-opacity="%.3f"`, float64(c.A)/255)
|
||||
}
|
||||
_, err = fmt.Fprintf(w, `<rect x="%d" y="%d" width="%d" height="1" fill="#%02x%02x%02x"%s/>`+"\n",
|
||||
x, y, run, c.R, c.G, c.B, opacity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
x += run
|
||||
}
|
||||
}
|
||||
_, err = io.WriteString(w, "</svg>\n")
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user