Files
aego/app/agent/assets.go
T
sanspie a6ebf841d0
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 9m10s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m25s
ci / release (push) Failing after 4m59s
Stage 11b8: agents draw their art (D363)
asset.pixels: pixel art as a character grid and a palette; frames of one size become an atlas with a sprite per frame and a flipbook clip; a preview image in the answer.
asset.svg: an SVG subset drawn by an in-tree rasterizer (format/svg) for tiles, UI and icons.
art module: project.aego art { palette pixelsPerUnit }, the default palette and scale for drawings; Project Health HLT009 for pixel art outside the palette.
The agent prompts say to draw instead of building sprites from rectangles.
Agent runs open the asset index shared without a warning; prefab.create on a scene open in the editor goes through the prefab service, so unsaved scenes work.
scene_run with shot names xvfb when there is no display; local make ci lists untracked files outside .gitignore.
2026-10-26 19:36:15 +03:00

266 lines
8.5 KiB
Go

package agent
import (
"bytes"
"errors"
"fmt"
"image"
"image/png"
"os"
"path"
"strings"
"aego/anim"
"aego/asset/pipeline"
"aego/core/errs"
"aego/core/ids"
"aego/engine/module"
"aego/format/aetext"
"aego/format/pixelart"
"aego/format/svg"
"aego/modules/art"
"aego/modules/render2d"
"aego/project/refactor"
)
// paletteKeys name the art palette's colors in a grid when the call gives no palette: 0-9, then a-z.
const paletteKeys = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func assetCommands() []*Command {
out := Arg{Name: "path", Type: "string", Doc: "PNG to write under assets/, such as assets/sprites/hero.png", Required: true, Pos: true}
replace := Arg{Name: "replace", Type: "boolean", Doc: "draw over an existing PNG; its id and the ids of same-named frames stay"}
return []*Command{
{Name: "asset.pixels", Write: true, Run: (*Session).assetPixels,
Doc: "draw a sprite as text: each character of grid is a pixel colored by palette; lines like idle: start frames of one size, which become an atlas with one sprite per frame and a looping flipbook clip. Answers with a preview image",
Args: []Arg{out,
{Name: "grid", Type: "string", Doc: "rows of characters, one per pixel; '.' is transparent; 'name:' lines start frames", Required: true},
{Name: "palette", Type: "string", Doc: `characters to colors: {"r": "#d0312d", "s": "#f4c7a1"} or r=#d0312d s=#f4c7a1; without it, 0-9 and a-z are the colors of project.aego's art palette`},
{Name: "scale", Type: "integer", Doc: "texture pixels per grid pixel; by default art.pixelsPerUnit scaled to the render pixelsPerUnit"},
{Name: "flipbook", Type: "boolean", Doc: "with several frames, also write <name>.aeanim that plays them; on by default"},
replace}},
{Name: "asset.svg", Write: true, Run: (*Session).assetSVG,
Doc: "draw a texture from SVG: path, rect, circle, ellipse, line, polyline, polygon, fill, stroke, opacity and transform; no filters, gradients or text. For tiles, UI and item icons. Answers with a preview image",
Args: []Arg{out,
{Name: "svg", Type: "string", Doc: "the SVG document, <svg viewBox=…>…</svg>", Required: true},
{Name: "width", Type: "integer", Doc: "texture width in pixels, 64 by default"},
{Name: "height", Type: "integer", Doc: "texture height in pixels, the width by default"},
replace}},
}
}
// art is the project's art section and the render pixels per unit, or zero values without them.
func (s *Session) art() (art.Art, render2d.Settings) {
render := render2d.DefaultSettings()
secs, err := s.sections()
if err != nil {
return art.Art{}, render
}
if v, ok := module.SectionValue[render2d.Settings](secs, render2d.SectionID); ok {
render = *v
}
if v, ok := module.SectionValue[art.Art](secs, art.SectionID); ok {
return *v, render
}
return art.Art{}, render
}
// DrawnView is what a drawing tool made.
type DrawnView struct {
Path string `json:"path"`
ID string `json:"id,omitempty"`
Size [2]int `json:"size"`
Frames []FrameView `json:"frames,omitempty"`
Flipbook string `json:"flipbook,omitempty"`
}
type FrameView struct {
Name string `json:"name"`
ID string `json:"id,omitempty"`
Rect [4]int `json:"rect"`
}
func (s *Session) assetPixels(a Args) (*Output, error) {
rel, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
style, render := s.art()
pal, err := pixelart.ParsePalette(a.Str("palette"))
if err != nil {
return nil, err
}
if !a.Has("palette") {
if len(style.Palette) == 0 {
return nil, errors.New("give a palette, or declare art { palette=[…] } in project.aego and use 0-9, a-z for its colors")
}
for i, c := range style.Palette {
pal[rune(paletteKeys[i])] = c
}
}
scale, err := a.Int("scale", style.Scale(render.PPU()))
if err != nil {
return nil, err
}
sheet, err := pixelart.Parse(a.Str("grid"), pal, scale)
if err != nil {
return nil, err
}
var notes []string
if len(style.Palette) > 0 && a.Has("palette") {
if off, opaque := pixelart.OffPalette(sheet.Image, style.Palette); off > 0 {
notes = append(notes, fmt.Sprintf("%d of %d pixels are outside the art palette of project.aego", off, opaque))
}
}
b := sheet.Image.Bounds()
preview := pixelart.Upscale(sheet.Image, max(1, 256/max(b.Dx(), b.Dy())))
base := strings.TrimSuffix(path.Base(rel), ".png")
var cells []pipeline.SubAsset
if len(sheet.Cells) > 1 {
for i, c := range sheet.Cells {
cells = append(cells, pipeline.SubAsset{Name: base + "_" + sheet.Names[i], Rect: [4]int{c.Min.X, c.Min.Y, c.Dx(), c.Dy()}})
}
}
flip := len(cells) > 1 && (!a.Has("flipbook") || a.Bool("flipbook"))
return s.drawn(a, rel, sheet.Image, preview, cells, flip, nil, notes)
}
func (s *Session) assetSVG(a Args) (*Output, error) {
rel, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
w, err := a.Int("width", 64)
if err != nil {
return nil, err
}
h, err := a.Int("height", w)
if err != nil {
return nil, err
}
r, err := svg.Render([]byte(a.Str("svg")), w, h)
if err != nil {
return nil, err
}
var notes []string
if len(r.Skipped) > 0 {
notes = append(notes, "not drawn, outside the supported SVG: "+strings.Join(r.Skipped, ", "))
}
preview := r.Image
if m := max(w, h); m < 128 {
preview = pixelart.Upscale(r.Image, 256/m)
}
linear := pipeline.Settings{"filter": aetext.Ident("linear")}
return s.drawn(a, rel, r.Image, preview, nil, false, linear, notes)
}
func pngPath(rel string) (string, error) {
rel = path.Clean(strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "./"))
if !strings.HasPrefix(rel, "assets/") || path.Ext(rel) != ".png" {
return "", errs.New(errs.VFSBadPath, "the path is a .png under assets/, such as assets/sprites/hero.png")
}
return rel, nil
}
// drawn writes the PNG, its frames and settings, and the flipbook clip; a dry run only answers with the preview.
func (s *Session) drawn(a Args, rel string, img, preview image.Image, cells []pipeline.SubAsset, flip bool, settings pipeline.Settings, notes []string) (*Output, error) {
var buf, pv bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
if err := png.Encode(&pv, preview); err != nil {
return nil, err
}
b := img.Bounds()
view := DrawnView{Path: rel, Size: [2]int{b.Dx(), b.Dy()}}
out := &Output{OK: true, Notes: notes, Images: []Image{{Mime: "image/png", Data: pv.Bytes()}}, Value: &view}
animRel := strings.TrimSuffix(rel, ".png") + ".aeanim"
if flip {
view.Flipbook = animRel
}
for _, c := range cells {
view.Frames = append(view.Frames, FrameView{Name: c.Name, Rect: c.Rect})
}
if a.Bool("dry-run") {
out.DryRun = true
return out, nil
}
if err := s.writable(); err != nil {
return nil, err
}
var id ids.AssetID
if s.exists(rel) {
if !a.Bool("replace") {
return nil, errs.New(errs.VFSBadPath, rel+" already exists; pass replace to draw over it")
}
before, err := os.ReadFile(s.Pipeline.Abs(rel))
if err != nil {
return nil, err
}
if _, err := s.save(rel, before, buf.Bytes()); err != nil {
return nil, err
}
id, _ = s.Pipeline.Lookup(rel)
} else {
var err error
if id, err = s.create(rel, buf.Bytes()); err != nil {
return nil, err
}
out.Created = append(out.Created, id.String())
}
view.ID = id.String()
meta, _ := s.Pipeline.MetaOf(id)
if settings != nil {
merged := meta.Settings.Clone()
for k, v := range settings {
merged[k] = v
}
if err := s.Pipeline.SetSettings(id, merged); err != nil {
return nil, err
}
}
if len(cells) > 0 || len(meta.Subs) > 0 {
old := map[string]ids.AssetID{}
for _, sub := range meta.Subs {
old[sub.Name] = sub.ID
}
for i := range cells {
if cells[i].ID = old[cells[i].Name]; cells[i].ID.IsZero() {
cells[i].ID = ids.NewAssetID()
}
view.Frames[i].ID = cells[i].ID.String()
out.Created = append(out.Created, cells[i].ID.String())
}
if err := s.Pipeline.SetSubs(id, cells); err != nil {
return nil, err
}
}
if _, err := refactor.Reimport(s.Pipeline, []string{rel}); err != nil {
return nil, err
}
out.Files = append(out.Files, rel)
if flip {
frames := make([]ids.AssetID, len(cells))
for i, c := range cells {
frames[i] = c.ID
}
text := anim.FlipbookText(frames)
if s.exists(animRel) {
before, err := os.ReadFile(s.Pipeline.Abs(animRel))
if err != nil {
return nil, err
}
if _, err := s.save(animRel, before, text); err != nil {
return nil, err
}
} else {
cid, err := s.create(animRel, text)
if err != nil {
return nil, err
}
out.Created = append(out.Created, cid.String())
}
out.Files = append(out.Files, animRel)
}
return out, nil
}