Files
aego/asset/importers/artsource/linked_texture_test.go

533 lines
20 KiB
Go

package artsource
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"strings"
"testing"
"aego/art"
"aego/asset"
texImporter "aego/asset/importers/texture"
"aego/asset/pipeline"
"aego/core/ids"
"aego/format/aetext"
)
func linkedTextureFixture(t *testing.T, sliced bool) (*pipeline.ImportContext, *art.Session, map[string][]byte, []TextureLinkFrame) {
t.Helper()
meta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
if sliced {
meta.Subs = []pipeline.SubAsset{
{ID: ids.NewAssetID(), Name: "left", Rect: [4]int{0, 0, 2, 2}, Pivot: [2]float32{.25, .75}, HasPivot: true},
{ID: ids.NewAssetID(), Name: "right", Rect: [4]int{2, 0, 2, 2}},
}
}
im := image.NewNRGBA(image.Rect(0, 0, 4, 2))
for y := 0; y < 2; y++ {
for x := 0; x < 4; x++ {
im.SetNRGBA(x, y, color.NRGBA{R: uint8(10 + x*20), G: uint8(30 + y*25), B: 90, A: 200})
}
}
var b bytes.Buffer
if err := png.Encode(&b, im); err != nil {
t.Fatal(err)
}
ctx := &pipeline.ImportContext{ID: meta.ID, Path: "assets/hero.png", Source: b.Bytes(), Meta: meta}
d, mapping, err := ImportTextureDocument(ctx)
if err != nil {
t.Fatal(err)
}
t.Cleanup(d.Close)
s, err := art.NewSession(d, art.NewJournal(art.DefaultLimits()))
if err != nil {
t.Fatal(err)
}
artMeta := pipeline.NewMeta("artsource", Version, New().Defaults())
encoded, err := d.Encode()
if err != nil {
t.Fatal(err)
}
files := map[string][]byte{"assets/hero.aeart": encoded, "assets/hero.aeart.meta": pipeline.WriteMeta(artMeta)}
ctx.Read = func(name string) ([]byte, error) { return bytes.Clone(files[name]), nil }
ctx.SourcePath = func(id ids.AssetID) (string, bool) {
if id == artMeta.ID {
return "assets/hero.aeart", true
}
return "", false
}
if err := SetTextureLink(meta, artMeta.ID, ctx.Source, mapping); err != nil {
t.Fatal(err)
}
return ctx, s, files, mapping
}
func preparedTexturePixels(t *testing.T, result *pipeline.Result) []byte {
t.Helper()
for _, artifact := range result.Artifacts {
if artifact.Kind == asset.ArtTexture {
texture, err := asset.DecodeTexture(artifact.Data)
if err != nil {
t.Fatal(err)
}
return bytes.Clone(texture.Pixels)
}
}
t.Fatal("missing runtime texture")
return nil
}
func TestSourceArtNormalUsesExistingDownYPreparation(t *testing.T) {
imageBytes := func(c color.NRGBA) []byte {
im := image.NewNRGBA(image.Rect(0, 0, 2, 2))
for y := 0; y < 2; y++ {
for x := 0; x < 2; x++ {
im.SetNRGBA(x, y, c)
}
}
var out bytes.Buffer
if err := png.Encode(&out, im); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
normal := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
normal.Settings["role"] = aetext.Ident("normal")
normal.Settings["normal_y"] = aetext.Ident("down")
colorMeta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
colorMeta.Settings["normal"] = aetext.AssetRef(normal.ID.String())
normalPNG := imageBytes(color.NRGBA{R: 31, G: 69, B: 250, A: 128})
files := map[string][]byte{"assets/source_n.png": normalPNG, "assets/source_n.png.meta": pipeline.WriteMeta(normal)}
ctx := &pipeline.ImportContext{ID: colorMeta.ID, Path: "assets/source.png", Source: imageBytes(color.NRGBA{R: 255, A: 255}), Meta: colorMeta,
Read: func(name string) ([]byte, error) { return files[name], nil }, SourcePath: func(id ids.AssetID) (string, bool) { return "assets/source_n.png", id == normal.ID }}
doc, frames, err := ImportTextureDocument(ctx)
if err != nil {
t.Fatal(err)
}
defer doc.Close()
prepared, err := texImporter.New().Import(&pipeline.ImportContext{ID: normal.ID, Path: "assets/source_n.png", Source: normalPNG, Meta: normal})
if err != nil {
t.Fatal(err)
}
pixels, err := doc.Render(frames[0].Frame, art.RenderOptions{Channel: art.Normal})
if err != nil || !bytes.Equal(pixels.Pix, preparedTexturePixels(t, prepared)) {
t.Fatal("linked source changed down-Y normal interpretation or alpha", err)
}
if pixels.NRGBAAt(0, 0) != (color.NRGBA{R: 31, G: 186, B: 250, A: 128}) {
t.Fatal("normal was not converted to canonical +Y pixels")
}
delete(colorMeta.Settings, "normal")
colorMeta.Settings["emission"] = aetext.AssetRef(normal.ID.String())
normal.Settings["role"] = aetext.Ident("emission-mask")
files["assets/source_n.png.meta"] = pipeline.WriteMeta(normal)
if doc, _, err := ImportTextureDocument(ctx); err == nil {
doc.Close()
t.Fatal("source conversion silently changed emission-mask color semantics")
}
}
func TestImportTextureDocumentPreservesStraightPalettePixels(t *testing.T) {
palette := color.Palette{
color.NRGBA{R: 71, G: 101, B: 151, A: 0},
color.NRGBA{R: 201, G: 19, B: 89, A: 1},
color.NRGBA{R: 143, G: 57, B: 211, A: 128},
color.NRGBA{R: 13, G: 203, B: 29, A: 255},
}
im := image.NewPaletted(image.Rect(0, 0, 4, 1), palette)
for x := 0; x < 4; x++ {
im.SetColorIndex(x, 0, uint8(x))
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, im); err != nil {
t.Fatal(err)
}
meta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
meta.Subs = []pipeline.SubAsset{
{ID: ids.NewAssetID(), Name: "first", Rect: [4]int{0, 0, 2, 1}},
{ID: ids.NewAssetID(), Name: "second", Rect: [4]int{2, 0, 2, 1}},
}
beforeSource, beforeMeta := bytes.Clone(encoded.Bytes()), pipeline.WriteMeta(meta)
doc, frames, err := ImportTextureDocument(&pipeline.ImportContext{ID: meta.ID, Path: "assets/indexed.png", Source: encoded.Bytes(), Meta: meta})
if err != nil {
t.Fatal(err)
}
defer doc.Close()
if !bytes.Equal(beforeSource, encoded.Bytes()) || !bytes.Equal(beforeMeta, pipeline.WriteMeta(meta)) || len(frames) != 2 {
t.Fatal("opening the PNG mutated its source, metadata or frame mapping")
}
check := func(d *art.Document) {
t.Helper()
info := d.Info()
for i, frame := range frames {
var cel art.ID
for _, candidate := range info.Cels {
if candidate.Frame == frame.Frame && candidate.Channel == art.Albedo {
cel = candidate.ID
break
}
}
for x := 0; x < 2; x++ {
want := palette[i*2+x].(color.NRGBA)
got, err := d.Pixel(cel, x, 0)
if err != nil || got != (art.Color{R: want.R, G: want.G, B: want.B, A: want.A}) {
t.Fatalf("frame %d pixel %d: got %+v (%v), want %+v", i, x, got, err, want)
}
}
}
}
check(doc)
saved, err := doc.Encode()
if err != nil {
t.Fatal(err)
}
reopened, err := art.Decode(saved, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
check(reopened)
}
func TestImportTextureDocumentRejectsSixteenBitWithoutMutation(t *testing.T) {
im := image.NewNRGBA64(image.Rect(0, 0, 1, 1))
im.SetNRGBA64(0, 0, color.NRGBA64{R: 0x1234, G: 0xabcd, B: 0x5678, A: 0xcdef})
var encoded bytes.Buffer
if err := png.Encode(&encoded, im); err != nil {
t.Fatal(err)
}
meta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
beforeSource, beforeMeta := bytes.Clone(encoded.Bytes()), pipeline.WriteMeta(meta)
doc, _, err := ImportTextureDocument(&pipeline.ImportContext{ID: meta.ID, Path: "assets/high-depth.png", Source: encoded.Bytes(), Meta: meta})
if doc != nil {
doc.Close()
}
if err == nil || !strings.Contains(err.Error(), "16-bit") {
t.Fatalf("source precision loss was not rejected: %v", err)
}
if !bytes.Equal(beforeSource, encoded.Bytes()) || !bytes.Equal(beforeMeta, pipeline.WriteMeta(meta)) {
t.Fatal("rejected PNG import changed source or metadata")
}
}
func TestImportTextureDocumentRejectsSixteenBitSurfaceMaps(t *testing.T) {
main := image.NewNRGBA(image.Rect(0, 0, 1, 1))
main.SetNRGBA(0, 0, color.NRGBA{R: 10, G: 20, B: 30, A: 255})
var mainPNG, surfacePNG bytes.Buffer
if err := png.Encode(&mainPNG, main); err != nil {
t.Fatal(err)
}
surface := image.NewNRGBA64(image.Rect(0, 0, 1, 1))
surface.SetNRGBA64(0, 0, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9abc, A: 0xffff})
if err := png.Encode(&surfacePNG, surface); err != nil {
t.Fatal(err)
}
for _, channel := range []string{"normal", "height", "emission"} {
t.Run(channel, func(t *testing.T) {
meta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
surfaceMeta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
meta.Settings[channel] = aetext.AssetRef(surfaceMeta.ID.String())
beforeMeta := pipeline.WriteMeta(meta)
files := map[string][]byte{
"assets/surface.png": bytes.Clone(surfacePNG.Bytes()),
"assets/surface.png.meta": pipeline.WriteMeta(surfaceMeta),
}
ctx := &pipeline.ImportContext{ID: meta.ID, Path: "assets/color.png", Source: bytes.Clone(mainPNG.Bytes()), Meta: meta,
Read: func(name string) ([]byte, error) { return bytes.Clone(files[name]), nil },
SourcePath: func(id ids.AssetID) (string, bool) { return "assets/surface.png", id == surfaceMeta.ID },
}
doc, _, err := ImportTextureDocument(ctx)
if doc != nil {
doc.Close()
}
if err == nil || !strings.Contains(err.Error(), "16-bit") {
t.Fatalf("%s precision loss was not rejected: %v", channel, err)
}
if !bytes.Equal(ctx.Source, mainPNG.Bytes()) || !bytes.Equal(files["assets/surface.png"], surfacePNG.Bytes()) || !bytes.Equal(beforeMeta, pipeline.WriteMeta(meta)) {
t.Fatal("rejected surface import changed source or metadata")
}
})
}
}
func TestLinkedPNGKeepsRuntimeIDsAndChangesOnlySelectedFrame(t *testing.T) {
for _, sliced := range []bool{false, true} {
t.Run(map[bool]string{false: "whole sprite", true: "sheet"}[sliced], func(t *testing.T) {
ctx, session, files, mapping := linkedTextureFixture(t, sliced)
original, err := texImporter.New().Import(ctx)
if err != nil {
t.Fatal(err)
}
linked, err := NewTexture().Import(ctx)
if err != nil {
t.Fatal(err)
}
before := preparedTexturePixels(t, original)
if !bytes.Equal(before, preparedTexturePixels(t, linked)) {
t.Fatal("initial linking changed prepared pixels")
}
if len(original.Subs) != len(linked.Subs) {
t.Fatal("linking changed sliced frame count")
}
for i, sub := range original.Subs {
if sub.ID != linked.Subs[i].ID || sub.Name != linked.Subs[i].Name || !bytes.Equal(sub.Artifacts[0].Data, linked.Subs[i].Artifacts[0].Data) {
t.Fatal("linking changed an existing frame identity, pivot or region")
}
}
frame := mapping[len(mapping)-1].Frame
if err := session.Select("", frame, art.Albedo); err != nil {
t.Fatal(err)
}
beforeSalt, err := NewTexture().CacheSalt(ctx)
if err != nil {
t.Fatal(err)
}
_, err = session.Execute(context.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: 1, Y: 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, B: 255, A: 255}}}})
if err != nil {
t.Fatal(err)
}
files["assets/hero.aeart"], err = session.Document.Encode()
if err != nil {
t.Fatal(err)
}
after, err := NewTexture().Import(ctx)
if err != nil {
t.Fatal(err)
}
pixels := preparedTexturePixels(t, after)
paintedOffset := (1*4 + 1) * 4
if sliced {
paintedOffset += 2 * 4
}
for i := range pixels {
if i < paintedOffset || i >= paintedOffset+4 {
if pixels[i] != before[i] {
t.Fatalf("unselected source pixel changed at byte %d", i)
}
}
}
if !bytes.Equal(pixels[paintedOffset:paintedOffset+4], []byte{255, 0, 255, 255}) {
t.Fatal("painting did not reach the existing runtime texture")
}
afterSalt, err := NewTexture().CacheSalt(ctx)
if err != nil || bytes.Equal(beforeSalt, afterSalt) {
t.Fatalf("authoring source changes did not invalidate import cache: %v", err)
}
if err := session.Undo(); err != nil {
t.Fatal(err)
}
files["assets/hero.aeart"], _ = session.Document.Encode()
undone, err := NewTexture().Import(ctx)
if err != nil || !bytes.Equal(preparedTexturePixels(t, undone), before) {
t.Fatalf("undo did not restore runtime source pixels: %v", err)
}
})
}
}
func TestLinkedPNGRejectsExternalSourceAndBrokenMappings(t *testing.T) {
for _, scenario := range []string{"PNG changed", "missing art", "metadata identity", "frame removed", "slicing changed", "slicing rectangles exchanged", "duplicate mapping", "duplicate authoring frame", "frame mapping exchanged"} {
t.Run(scenario, func(t *testing.T) {
ctx, session, files, mapping := linkedTextureFixture(t, true)
switch scenario {
case "PNG changed":
ctx.Source = append(bytes.Clone(ctx.Source), 1)
case "missing art":
delete(files, "assets/hero.aeart")
case "metadata identity":
files["assets/hero.aeart.meta"] = pipeline.WriteMeta(pipeline.NewMeta("artsource", Version, New().Defaults()))
case "frame removed":
if _, err := session.Execute(context.Background(), art.Operation{Kind: "delete-frame", Frame: mapping[1].Frame}); err != nil {
t.Fatal(err)
}
files["assets/hero.aeart"], _ = session.Document.Encode()
case "slicing changed":
ctx.Meta.Subs = ctx.Meta.Subs[:1]
case "slicing rectangles exchanged":
// The IDs and rectangle sizes remain valid, but accepting this
// would silently paint each authored frame into the other region.
ctx.Meta.Subs[0].Rect, ctx.Meta.Subs[1].Rect = ctx.Meta.Subs[1].Rect, ctx.Meta.Subs[0].Rect
case "duplicate mapping":
values := ctx.Meta.Settings["art_frames"]
values.List[1] = values.List[0]
ctx.Meta.Settings["art_frames"] = values
case "duplicate authoring frame":
values := ctx.Meta.Settings["art_frames"]
values.List[1] = aetext.Struct(
aetext.Field{Name: "asset", Value: aetext.AssetRef(mapping[1].Asset.String())},
aetext.Field{Name: "frame", Value: aetext.String(string(mapping[0].Frame))},
)
ctx.Meta.Settings["art_frames"] = values
case "frame mapping exchanged":
values := ctx.Meta.Settings["art_frames"]
values.List[0] = aetext.Struct(
aetext.Field{Name: "asset", Value: aetext.AssetRef(mapping[0].Asset.String())},
aetext.Field{Name: "frame", Value: aetext.String(string(mapping[1].Frame))},
)
values.List[1] = aetext.Struct(
aetext.Field{Name: "asset", Value: aetext.AssetRef(mapping[1].Asset.String())},
aetext.Field{Name: "frame", Value: aetext.String(string(mapping[0].Frame))},
)
ctx.Meta.Settings["art_frames"] = values
}
if _, err := NewTexture().Import(ctx); err == nil {
t.Fatal("unsafe linked source was accepted")
}
})
}
}
func TestLinkedPNGGeometryGuardAllowsReorderAndRecognizesLegacyLinks(t *testing.T) {
ctx, _, _, _ := linkedTextureFixture(t, true)
link, err := TextureLinkOf(ctx.Meta)
if err != nil || link == nil || link.LayoutSHA256 == "" {
t.Fatalf("new link has no geometry guard: %+v, %v", link, err)
}
// Reordering, renaming and pivot edits do not change a region's rectangle
// or identity. They must not invalidate the authoring-frame mapping.
ctx.Meta.Subs[0], ctx.Meta.Subs[1] = ctx.Meta.Subs[1], ctx.Meta.Subs[0]
ctx.Meta.Subs[0].Name = "renamed"
ctx.Meta.Subs[0].Pivot, ctx.Meta.Subs[0].HasPivot = [2]float32{0.1, 0.8}, true
values := ctx.Meta.Settings["art_frames"]
values.List[0], values.List[1] = values.List[1], values.List[0]
ctx.Meta.Settings["art_frames"] = values
if link, err := TextureLinkOf(ctx.Meta); err != nil || link.LayoutSHA256 == "" {
t.Fatalf("non-geometric metadata edit invalidated the link: %+v, %v", link, err)
}
// Existing projects may contain pre-guard metadata. Reading it is retained
// for compatibility, but an absent fingerprint is not presented as guarded.
delete(ctx.Meta.Settings, "art_source_layout_sha256")
legacy, err := TextureLinkOf(ctx.Meta)
if err != nil || legacy == nil || legacy.LayoutSHA256 != "" {
t.Fatalf("legacy link could not be read as unguarded: %+v, %v", legacy, err)
}
result, err := NewTexture().Import(ctx)
if err != nil || result == nil || result.Error != nil {
t.Fatalf("legacy linked source no longer imports: %+v, %v", result, err)
}
}
func TestSetTextureLinkRejectsDuplicateFrameWithoutChangingMetadata(t *testing.T) {
meta := pipeline.NewMeta("texture", texImporter.Version, asset.TextureDefaults())
before := pipeline.WriteMeta(meta)
frames := []TextureLinkFrame{{Asset: ids.NewAssetID(), Frame: "0000000000000001"},
{Asset: ids.NewAssetID(), Frame: "0000000000000001"}}
if err := SetTextureLink(meta, ids.NewAssetID(), []byte("source"), frames); err == nil {
t.Fatal("two distinct source assets were mapped to the same frame")
}
if after := pipeline.WriteMeta(meta); !bytes.Equal(before, after) {
t.Fatal("rejected link changed the original metadata")
}
}
func TestLinkedPNGEmissionUsesRealPreparedArtFrames(t *testing.T) {
ctx, session, files, mapping := linkedTextureFixture(t, true)
if err := session.Select("", mapping[1].Frame, art.Emission); err != nil {
t.Fatal(err)
}
if _, err := session.Execute(context.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: 1, Y: 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, G: 40, A: 255}}}}); err != nil {
t.Fatal(err)
}
files["assets/hero.aeart"], _ = session.Document.Encode()
linked, err := NewTexture().Import(ctx)
if err != nil {
t.Fatal(err)
}
link, _ := TextureLinkOf(ctx.Meta)
artMeta, err := pipeline.ParseMeta(files["assets/hero.aeart.meta"], "assets/hero.aeart.meta")
if err != nil {
t.Fatal(err)
}
prepared, err := New().Import(&pipeline.ImportContext{ID: link.Source, Path: "assets/hero.aeart", Source: files["assets/hero.aeart"], Meta: artMeta})
if err != nil {
t.Fatal(err)
}
region, err := asset.DecodeRegion(linked.Subs[1].Artifacts[0].Data)
if err != nil || region.Emission.IsZero() {
t.Fatalf("original sprite region has no emission binding: %v", err)
}
for _, sub := range prepared.Subs {
if sub.ID == region.Emission && sub.Name == FrameName(mapping[1].Frame, art.Emission) {
return
}
}
t.Fatal("emission binding does not resolve to a prepared channel frame")
}
func TestTextureLinkMetadataValidation(t *testing.T) {
ctx, _, _, _ := linkedTextureFixture(t, false)
for _, bad := range []aetext.Value{aetext.String("arbitrary path"), aetext.AssetRef(ctx.ID.String()), aetext.AssetRef("bad")} {
ctx.Meta.Settings["art_source"] = bad
if _, err := TextureLinkOf(ctx.Meta); err == nil || !strings.Contains(err.Error(), "artsource") {
t.Fatalf("invalid authoring reference accepted: %v", err)
}
}
}
func TestLinkedPNGExplicitPivotRetainsLegacyIdentityAndMetadata(t *testing.T) {
ctx, session, files, mapping := linkedTextureFixture(t, true)
before := pipeline.WriteMeta(ctx.Meta)
var pivot art.Slice
for _, slice := range session.Document.Info().Slices {
if slice.Frame == mapping[0].Frame && slice.Name == "pivot" {
pivot = slice
}
}
pivot.Pivot = image.Pt(0, 2)
if _, err := session.Execute(context.Background(), art.Operation{Kind: "slice", Slice: &pivot}); err != nil {
t.Fatal(err)
}
var err error
files["assets/hero.aeart"], err = session.Document.Encode()
if err != nil {
t.Fatal(err)
}
result, err := NewTexture().Import(ctx)
if err != nil {
t.Fatal(err)
}
found := false
for _, sub := range result.Subs {
if sub.ID != mapping[0].Asset {
continue
}
for _, artifact := range sub.Artifacts {
if artifact.Kind != asset.ArtSpriteRegion {
continue
}
region, err := asset.DecodeRegion(artifact.Data)
if err != nil {
t.Fatal(err)
}
if !region.HasPivot || region.Pivot != ([2]float32{0, 1}) {
t.Fatal("authoring pivot did not reach prepared legacy region", region.Pivot)
}
found = true
}
}
if !found || !bytes.Equal(before, pipeline.WriteMeta(ctx.Meta)) {
t.Fatal("pivot edit replaced legacy ID or changed original metadata")
}
if err := session.Undo(); err != nil {
t.Fatal(err)
}
files["assets/hero.aeart"], err = session.Document.Encode()
if err != nil {
t.Fatal(err)
}
result, err = NewTexture().Import(ctx)
if err != nil {
t.Fatal(err)
}
for _, sub := range result.Subs {
if sub.ID == mapping[0].Asset {
region, err := asset.DecodeRegion(sub.Artifacts[0].Data)
if err != nil || region.Pivot != ([2]float32{.25, .75}) {
t.Fatal("pivot Undo did not preserve the exact original fractional pivot", err)
}
}
}
}