Files

280 lines
10 KiB
Go

// Package artsource prepares editable art using Aego's existing texture and
// animation codecs. It does not introduce a new runtime resource format.
package artsource
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"aego/anim"
"aego/art"
"aego/asset"
"aego/asset/importers/animclip"
texImporter "aego/asset/importers/texture"
"aego/asset/pipeline"
"aego/core/curve"
"aego/core/ids"
"aego/format/aetext"
"aego/gpu"
"aego/schema"
)
const Version = 1
const Extension = ".aeart"
type Importer struct{}
func New() *Importer { return &Importer{} }
func (*Importer) ID() string { return "artsource" }
func (*Importer) Version() int { return Version }
func (*Importer) Extensions() []string { return []string{Extension} }
func (*Importer) Defaults() pipeline.Settings { return asset.TextureDefaults() }
func (*Importer) SourceEditLimit() int64 { return art.DefaultLimits().MaxFileBytes }
// SubName addresses immutable entity IDs, never frame positions or tag names.
// Reordering frames, renaming tags and repainting therefore preserve AssetRefs.
func FrameName(frame art.ID, channel art.Channel) string {
return "frame_" + string(frame) + "_" + string(channel)
}
func ClipName(tag art.ID) string {
if tag == "" {
return "animation"
}
return "tag_" + string(tag)
}
func StableID(source ids.AssetID, name string) ids.AssetID {
sum := sha256.Sum256([]byte("aego.artsource.v1\x00" + source.String() + "\x00" + name))
id, _ := ids.ParseAssetID(hex.EncodeToString(sum[:16]))
return id
}
func sourceID(meta *pipeline.Meta, root ids.AssetID, name string) ids.AssetID {
// Explicit existing metadata always wins, including imported legacy mappings.
for _, s := range meta.Subs {
if s.Name == name && !s.ID.IsZero() {
return s.ID
}
}
return StableID(root, name)
}
func (*Importer) Import(ctx *pipeline.ImportContext) (*pipeline.Result, error) {
if ctx == nil || ctx.Meta == nil || ctx.ID.IsZero() {
return nil, fmt.Errorf("artsource: source identity and metadata are required")
}
if ctx.Meta.Settings.String("atlas", "") != "" || ctx.Meta.Settings.Bool("trim", false) || ctx.Meta.Settings.Bool("rotate", false) || ctx.Meta.Settings.Bool("mips", false) {
return nil, fmt.Errorf("artsource: frame sheets cannot use atlas packing, trim, rotation or sheet-wide mipmaps")
}
// Reject unsupported external bindings explicitly rather than discarding them.
for _, key := range []string{"normal", "emission", "height", "normal_frames", "emission_frames", "height_frames"} {
if _, exists := ctx.Meta.Settings[key]; exists {
return nil, fmt.Errorf("artsource: %s is an external surface binding; use embedded channels in this source", key)
}
}
if ctx.Meta.Settings.Bool("generate_normal", false) {
return nil, fmt.Errorf("artsource: generated normals are not supported for embedded frame sheets; paint a normal channel")
}
seenNames := map[string]bool{}
seenIDs := map[ids.AssetID]bool{ctx.ID: true}
for _, sub := range ctx.Meta.Subs {
if sub.Name == "" || seenNames[sub.Name] || sub.ID.IsZero() || seenIDs[sub.ID] {
return nil, fmt.Errorf("artsource: invalid or duplicate source subasset identity")
}
seenNames[sub.Name], seenIDs[sub.ID] = true, true
}
if len(ctx.Source) <= 4096 && len(bytes.Trim(ctx.Source, " \t\r\n")) == 0 {
return nil, fmt.Errorf("artsource: empty .aeart file; create a drawing in Aego using Create → Pixel Art, or initialize the blank file in the art editor")
}
if len(ctx.Source) >= 6 && ctx.Source[4] == 0xe0 && ctx.Source[5] == 0xa5 {
return nil, fmt.Errorf("artsource: this is an Aseprite file, not an .aeart document; import the original .ase or .aseprite file with Import Aseprite")
}
d, err := art.Decode(ctx.Source, art.DefaultLimits())
if err != nil {
return nil, fmt.Errorf("artsource: invalid .aeart document; use Create → New Pixel Art or import an Aseprite file instead of renaming it: %w", err)
}
defer d.Close()
snapshot, err := d.Snapshot()
if err != nil {
return nil, err
}
info := snapshot.Info()
columns := int(math.Ceil(math.Sqrt(float64(len(info.Frames)))))
channels := []art.Channel{art.Albedo}
for _, ch := range []art.Channel{art.Normal, art.Height, art.Emission} {
for _, c := range info.Cels {
if c.Channel == ch {
channels = append(channels, ch)
break
}
}
}
type prepared struct {
page ids.AssetID
sheet art.Sheet
result *pipeline.Result
}
preparedChannels := map[art.Channel]prepared{}
for _, ch := range channels {
sheet, err := snapshot.ExportSheet(columns, ch)
if err != nil {
return nil, fmt.Errorf("artsource %s sheet: %w", ch, err)
}
pageID := ctx.ID
if ch != art.Albedo {
pageID = sourceID(ctx.Meta, ctx.ID, "surface_"+string(ch))
}
settings := asset.TextureDefaults()
for k, v := range ctx.Meta.Settings {
settings[k] = v
}
settings["atlas"] = aetext.String("")
settings["trim"] = aetext.Bool(false)
settings["auto_normal"] = aetext.Bool(false)
settings["auto_emission"] = aetext.Bool(false)
// Bind embedded surfaces after importing; they are not external files.
for _, key := range []string{"normal", "emission", "height", "normal_frames", "emission_frames", "height_frames"} {
delete(settings, key)
}
role := "color"
switch ch {
case art.Normal:
role = "normal"
case art.Height:
role = "height"
case art.Emission:
role = "emission-rgb"
}
settings["role"] = aetext.String(role)
settings["semantic"] = aetext.String("auto")
if ch == art.Normal || ch == art.Height {
settings["colorspace"] = aetext.String("linear")
}
settings["generate_normal"] = aetext.Bool(false)
meta := &pipeline.Meta{ID: pageID, Importer: "texture", Version: texImporter.Version, Settings: settings}
local := *ctx
local.ID, local.Path, local.Source, local.Meta = pageID, ctx.Path+"."+string(ch)+".png", sheet.PNG, meta
result, err := texImporter.New().Import(&local)
if err != nil {
return nil, fmt.Errorf("artsource %s import: %w", ch, err)
}
if result.Error != nil {
return nil, result.Error
}
preparedChannels[ch] = prepared{pageID, sheet, result}
}
albedo := preparedChannels[art.Albedo]
result := &pipeline.Result{Kind: asset.ArtTexture}
for _, a := range albedo.result.Artifacts {
if a.Kind == asset.ArtTexture {
result.Artifacts = append(result.Artifacts, a)
}
}
filter := gpu.FilterNearest
if ctx.Meta.Settings.String("filter", "nearest") == "linear" {
filter = gpu.FilterLinear
}
frameIDs := map[art.ID]ids.AssetID{}
for _, ch := range channels {
p := preparedChannels[ch]
if ch != art.Albedo {
var textures []pipeline.Produced
for _, a := range p.result.Artifacts {
if a.Kind == asset.ArtTexture {
textures = append(textures, a)
}
}
result.Subs = append(result.Subs, pipeline.SubResult{ID: p.page, Name: "surface_" + string(ch), Kind: asset.ArtTexture, Artifacts: textures})
}
for i, f := range p.sheet.Frames {
name := FrameName(f.ID, ch)
id := sourceID(ctx.Meta, ctx.ID, name)
region := &asset.RegionArtifact{Page: p.page, X: f.Rect.Min.X, Y: f.Rect.Min.Y, W: f.Rect.Dx(), H: f.Rect.Dy(), PageW: p.sheet.Width, PageH: p.sheet.Height, Filter: filter, Pivot: ctx.Meta.Pivot, HasPivot: ctx.Meta.HasPivot}
if pivot, ok := info.FramePivot(f.ID); ok {
region.Pivot = pixelPivotToNormalized(pivot, info.Canvas.Width, info.Canvas.Height)
region.HasPivot = true
}
deps := []ids.AssetID{p.page}
if ch == art.Albedo {
frameIDs[f.ID] = id
if _, ok := preparedChannels[art.Normal]; ok {
region.Normal = sourceID(ctx.Meta, ctx.ID, FrameName(f.ID, art.Normal))
deps = append(deps, region.Normal)
}
if _, ok := preparedChannels[art.Emission]; ok {
region.Emission = sourceID(ctx.Meta, ctx.ID, FrameName(f.ID, art.Emission))
deps = append(deps, region.Emission)
}
}
bytes := asset.EncodeRegion(region)
result.Subs = append(result.Subs, pipeline.SubResult{ID: id, Name: name, Kind: asset.ArtSpriteRegion, Deps: deps, Artifacts: []pipeline.Produced{{Kind: asset.ArtSpriteRegion, Data: bytes}}})
if ch == art.Albedo && i == 0 {
// A SpriteRenderer assigned to the source shows its first frame,
// not the entire sheet; existing source AssetID remains unchanged.
result.Artifacts = append(result.Artifacts, pipeline.Produced{Kind: asset.ArtSpriteRegion, Name: ctx.ID.String() + ".aereg", Data: bytes})
}
}
}
tags := []art.ID{""}
for _, tag := range info.Tags {
tags = append(tags, tag.ID)
}
for _, tag := range tags {
text, err := ClipText(snapshot, tag, frameIDs)
if err != nil {
return nil, err
}
name := ClipName(tag)
clipID := sourceID(ctx.Meta, ctx.ID, name)
local := *ctx
local.ID, local.Path, local.Source = clipID, ctx.Path+"#"+name+".aeanim", text
clip, err := animclip.New().Import(&local)
if err != nil {
return nil, err
}
if clip.Error != nil {
return nil, clip.Error
}
result.Subs = append(result.Subs, pipeline.SubResult{ID: clipID, Name: name, Kind: asset.ArtAnim, Deps: clip.Deps, Artifacts: clip.Artifacts})
}
used := map[ids.AssetID]bool{ctx.ID: true}
for _, sub := range result.Subs {
if used[sub.ID] {
return nil, fmt.Errorf("artsource: prepared subasset identity collision: %s", sub.Name)
}
used[sub.ID] = true
}
return result, nil
}
// ClipText uses the engine's codec and variable-duration step keys. The tag
// order is expanded once, so Ping-pong never doubles its endpoint holds.
func ClipText(snapshot *art.Snapshot, tag art.ID, frames map[art.ID]ids.AssetID) ([]byte, error) {
if snapshot == nil {
return nil, fmt.Errorf("artsource: snapshot required")
}
order, err := snapshot.PlaybackFrames(tag)
if err != nil {
return nil, err
}
clip := &anim.Clip{Loop: anim.LoopRepeat}
track := anim.Track{Property: anim.SpriteProp, Kind: schema.KindAssetRef}
var ms int64
for _, frame := range order {
id := frames[frame.ID]
if id.IsZero() {
return nil, fmt.Errorf("artsource: unresolved frame %s", frame.ID)
}
track.Keys = append(track.Keys, anim.Key{T: float32(float64(ms) / 1000), V: anim.Asset(id), Interp: curve.InterpStep})
ms += int64(frame.DurationMS)
}
clip.Length = float32(float64(ms) / 1000)
clip.Tracks = []anim.Track{track}
if problems := clip.Check(nil); len(problems) != 0 {
return nil, fmt.Errorf("artsource: invalid generated animation: %+v", problems[0])
}
return aetext.Write(anim.Encode(clip)), nil
}