Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,3 +708,14 @@ The tool deletes `tooling/build` at the end of a run, as it always has. A work
directory you chose yourself is kept unless you pass `--clean`, and `--no-clean`
keeps the default one too. Cleanup refuses to run when the work directory
contains the current directory or the output directory.

### Failures stop the run

A problem part-way through never produces a run that looks successful. A render
directory that is missing or cannot be read, a file in it that is not a readable
image, an input that leaves nothing to pack, and a page or coordinates file that
cannot be written all end the run with a nonzero exit status. The coordinates
JSON is written only after every page is on disk, so a failed run cannot leave
an index that points at pages which do not exist. A build that drives the
tooling can stop there instead of packaging partial or stale assets. The render
step fails the same way when an icon cannot be drawn.
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ protected BufferedImage resizeToLayoutSize(BufferedImage source) {
protected abstract JsonObject entryToJson(AtlasEntry entry);

/**
* Loads, packs and writes the atlas. Fails with an {@link UncheckedIOException} rather than leaving a
* half-written set of files behind.
* Loads, packs and writes the atlas. Nothing to pack is an {@link IllegalStateException} and a file that
* cannot be written an {@link UncheckedIOException}: a run that reports success without producing the atlas,
* or leaves a half-written set of files behind, would let a build package stale or partial assets.
*/
public void generate() {
List<AtlasEntry> entries = loadTextures();
System.out.println("Loaded " + entries.size() + " textures.");

if (entries.isEmpty()) {
System.err.println("No textures to pack!");
return;
throw new IllegalStateException("No textures to pack into " + atlasOutputPath);
}

int capacity = layout.capacity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public static void main(String[] args) {
System.out.println("\n" + "=".repeat(50));
System.out.println("Overlay generation complete!");
System.out.println("=".repeat(50));
} catch (IOException | UncheckedIOException | IllegalArgumentException e) {
} catch (IOException | UncheckedIOException | IllegalArgumentException | IllegalStateException e) {
System.err.println("Failed to generate overlays: " + e.getMessage());
e.printStackTrace();
System.exit(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;

/**
* Generates the item texture atlas from a directory of individual PNG renders.
Expand Down Expand Up @@ -60,7 +61,7 @@ public static void main(String[] args) {

new SpritesheetGenerator(inputDir, paths, layout).generate();
System.out.println("Texture atlas generation complete!");
} catch (IllegalArgumentException | UncheckedIOException e) {
} catch (IllegalArgumentException | IllegalStateException | UncheckedIOException e) {
System.err.println("Error: " + e.getMessage());
System.exit(1);
}
Expand Down Expand Up @@ -95,52 +96,38 @@ private void loadTrimMaterials() {
}
}

/**
* Reads every PNG in the input directory in name order. A directory that cannot be listed and a file that
* is not a readable image both end the run: skipping them would pack an atlas with holes and report success,
* which is worse for a build than a clear failure.
*/
@Override
protected List<AtlasEntry> loadTextures() {
loadTrimMaterials();

List<AtlasEntry> textures = new ArrayList<>();
File folder = inputDir.toFile();
File[] files = folder.listFiles(f -> f.isFile() && f.getName().toLowerCase().endsWith(".png"));

if (files == null) {
System.err.println("No textures found in: " + folder.getAbsolutePath());
return textures;
}

System.out.println("Loading textures from: " + folder.getAbsolutePath());
Arrays.sort(files, Comparator.comparing(File::getName));
List<Path> files = listPngs();
System.out.println("Loading textures from: " + inputDir.toAbsolutePath());

List<AtlasEntry> textures = new ArrayList<>();
int skipped = 0;
int trimFiltered = 0;

for (File file : files) {
String name = file.getName().replace(".png", "");
for (Path file : files) {
String name = file.getFileName().toString().replace(".png", "");

if (isArmorTrimVariant(name)) {
trimFiltered++;
continue;
}

BufferedImage image = loadImage(file);
if (image == null) {
System.err.println("Failed to load: " + file.getName());
BufferedImage processed = resizeToLayoutSize(readImage(file));

if (ImageUtil.isFullyTransparent(processed)) {
skipped++;
continue;
}

try {
BufferedImage processed = resizeToLayoutSize(image);

if (ImageUtil.isFullyTransparent(processed)) {
skipped++;
continue;
}

textures.add(new AtlasEntry(name, processed));
} catch (Exception e) {
System.err.println("Failed to process: " + file.getName());
e.printStackTrace();
}
textures.add(new AtlasEntry(name, processed));
}

if (skipped > 0) {
Expand All @@ -154,11 +141,30 @@ protected List<AtlasEntry> loadTextures() {
return textures;
}

private BufferedImage loadImage(File file) {
private List<Path> listPngs() {
try (Stream<Path> listing = Files.list(inputDir)) {
return listing
.filter(Files::isRegularFile)
.filter(file -> file.getFileName().toString().toLowerCase().endsWith(".png"))
.sorted(Comparator.comparing(file -> file.getFileName().toString()))
.toList();
} catch (IOException e) {
throw new UncheckedIOException("Cannot read texture input directory " + inputDir.toAbsolutePath(), e);
}
}

/**
* @throws UncheckedIOException when the file cannot be read, or is not in a format ImageIO understands
*/
private static BufferedImage readImage(Path file) {
try {
return ImageIO.read(file);
BufferedImage image = ImageIO.read(file.toFile());
if (image == null) {
throw new IOException("not a readable image");
}
return image;
} catch (IOException e) {
return null;
throw new UncheckedIOException("Failed to load texture " + file + ": " + e.getMessage(), e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class AtlasGeneratorPagingTest {
Expand All @@ -31,7 +32,7 @@ class AtlasGeneratorPagingTest {
@Test
void defaultLayoutPacksSixteenTilesPerRowWithoutGutters() throws IOException {
List<AtlasGenerator.AtlasEntry> entries = solidEntries(17, 256);
new TestAtlas(AtlasLayout.DEFAULT, entries).generate();
atlas(AtlasLayout.DEFAULT, entries).generate();

BufferedImage page = ImageIO.read(directory.resolve("atlas.png").toFile());
assertEquals(4096, page.getWidth());
Expand All @@ -52,7 +53,7 @@ void defaultLayoutPacksSixteenTilesPerRowWithoutGutters() throws IOException {
@Test
void pagesKeepPixelsAndTransparentGutters() throws IOException {
List<AtlasGenerator.AtlasEntry> entries = List.of(entry("red", RED, 2), entry("green", GREEN, 2), entry("blue", BLUE, 2));
new TestAtlas(new AtlasLayout(2, 8, 4, 1), entries).generate();
atlas(new AtlasLayout(2, 8, 4, 1), entries).generate();

BufferedImage first = ImageIO.read(directory.resolve("atlas.png").toFile());
BufferedImage second = ImageIO.read(directory.resolve("atlas-1.png").toFile());
Expand All @@ -74,7 +75,7 @@ void pagesKeepPixelsAndTransparentGutters() throws IOException {

@Test
void singlePageKeepsTheOriginalFilename() throws IOException {
new TestAtlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();
atlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();

assertTrue(Files.isRegularFile(directory.resolve("atlas.png")));
assertFalse(Files.exists(directory.resolve("atlas-0.png")));
Expand All @@ -89,7 +90,7 @@ void singlePageKeepsTheOriginalFilename() throws IOException {

@Test
void exactlyCapacityEntriesFillOnePage() throws IOException {
new TestAtlas(new AtlasLayout(2, 4, 4, 0), solidEntries(4, 2)).generate();
atlas(new AtlasLayout(2, 4, 4, 0), solidEntries(4, 2)).generate();

assertTrue(Files.exists(directory.resolve("atlas.png")));
assertFalse(Files.exists(directory.resolve("atlas-1.png")));
Expand All @@ -100,7 +101,7 @@ void exactlyCapacityEntriesFillOnePage() throws IOException {

@Test
void oneEntryOverCapacityStartsASecondPage() throws IOException {
new TestAtlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();
atlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();

assertTrue(Files.exists(directory.resolve("atlas-1.png")));
assertFalse(Files.exists(directory.resolve("atlas-2.png")));
Expand All @@ -116,7 +117,7 @@ void paddedLayoutOffsetsBothAxesAndLeavesGuttersBetweenRows() throws IOException
List<AtlasGenerator.AtlasEntry> entries = List.of(
entry("a", RED, 2), entry("b", GREEN, 2), entry("c", BLUE, 2), entry("d", RED, 2)
);
new TestAtlas(new AtlasLayout(2, 8, 8, 1), entries).generate();
atlas(new AtlasLayout(2, 8, 8, 1), entries).generate();

BufferedImage page = ImageIO.read(directory.resolve("atlas.png").toFile());
assertEquals(8, page.getWidth());
Expand All @@ -137,7 +138,7 @@ void paddedLayoutOffsetsBothAxesAndLeavesGuttersBetweenRows() throws IOException

@Test
void lastPageIsOnlyAsTallAsItsRows() throws IOException {
new TestAtlas(new AtlasLayout(2, 4, 8, 0), solidEntries(10, 2)).generate();
atlas(new AtlasLayout(2, 4, 8, 0), solidEntries(10, 2)).generate();

BufferedImage first = ImageIO.read(directory.resolve("atlas.png").toFile());
BufferedImage second = ImageIO.read(directory.resolve("atlas-1.png").toFile());
Expand All @@ -151,7 +152,7 @@ void stalePagesFromAnEarlierRunAreRemoved() throws IOException {
Files.write(directory.resolve("atlas-1.png"), new byte[]{1, 2, 3});
Files.write(directory.resolve("atlas-2.png"), new byte[]{1, 2, 3});

new TestAtlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();
atlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();

assertFalse(Files.exists(directory.resolve("atlas-1.png")));
assertFalse(Files.exists(directory.resolve("atlas-2.png")));
Expand All @@ -163,7 +164,7 @@ void stalePagesBeyondAGapAreRemovedAndUnrelatedFilesAreKept() throws IOException
Files.write(directory.resolve("other-1.png"), new byte[]{1, 2, 3});
Files.write(directory.resolve("atlas-x.png"), new byte[]{1, 2, 3});

new TestAtlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();
atlas(new AtlasLayout(2, 4, 4, 0), List.of(entry("red", RED, 2))).generate();

assertFalse(Files.exists(directory.resolve("atlas-3.png")));
assertTrue(Files.exists(directory.resolve("other-1.png")), "a different atlas's pages are left alone");
Expand All @@ -175,15 +176,15 @@ void aShrinkingRunKeepsThePagesItWroteAndRemovesTheRest() throws IOException {
Files.write(directory.resolve("atlas-1.png"), new byte[]{1, 2, 3});
Files.write(directory.resolve("atlas-2.png"), new byte[]{1, 2, 3});

new TestAtlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();
atlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();

assertEquals(4, ImageIO.read(directory.resolve("atlas-1.png").toFile()).getWidth());
assertFalse(Files.exists(directory.resolve("atlas-2.png")));
}

@Test
void coordinatesCarryPageAndAtlasButNotPageDimensions() throws IOException {
new TestAtlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();
atlas(new AtlasLayout(2, 4, 4, 0), solidEntries(5, 2)).generate();

JsonArray json = readCoordinates();
assertEquals(5, json.size());
Expand All @@ -194,23 +195,26 @@ void coordinatesCarryPageAndAtlasButNotPageDimensions() throws IOException {

@Test
void pageOutputPathNumbersPagesBeforeTheExtension() {
TestAtlas atlas = new TestAtlas(AtlasLayout.DEFAULT, List.of());
TestAtlas atlas = atlas(AtlasLayout.DEFAULT, List.of());
assertEquals(directory.resolve("atlas.png"), atlas.pageOutputPath(0));
assertEquals(directory.resolve("atlas-2.png"), atlas.pageOutputPath(2));
}

@Test
void pageOutputPathIgnoresDotsInDirectoryNames() {
Path output = directory.resolve("v26.3").resolve("atlas");
TestAtlas atlas = new TestAtlas(AtlasLayout.DEFAULT, List.of(), output);
TestAtlas atlas = atlas(AtlasLayout.DEFAULT, List.of(), output);
assertEquals(output, atlas.pageOutputPath(0));
assertEquals(directory.resolve("v26.3").resolve("atlas-1"), atlas.pageOutputPath(1));
}

@Test
void generatingNothingWritesNoFiles() {
new TestAtlas(new AtlasLayout(2, 4, 4, 0), List.of()).generate();
void generatingNothingIsAnErrorAndWritesNoFiles() {
TestAtlas atlas = atlas(new AtlasLayout(2, 4, 4, 0), List.of());

IllegalStateException error = assertThrows(IllegalStateException.class, atlas::generate);

assertTrue(error.getMessage().contains("atlas.png"), error.getMessage());
assertFalse(Files.exists(directory.resolve("atlas.png")));
assertFalse(Files.exists(directory.resolve("coordinates.json")));
}
Expand Down Expand Up @@ -253,32 +257,11 @@ private static void assertTransparent(BufferedImage image, int x0, int y0, int w
}
}

private class TestAtlas extends AtlasGenerator {

private final List<AtlasEntry> entries;

TestAtlas(AtlasLayout layout, List<AtlasEntry> entries) {
this(layout, entries, directory.resolve("atlas.png"));
}

TestAtlas(AtlasLayout layout, List<AtlasEntry> entries, Path atlasOutputPath) {
super(layout, atlasOutputPath, directory.resolve("coordinates.json"));
this.entries = entries;
}

@Override
protected List<AtlasEntry> loadTextures() {
return entries;
}
private TestAtlas atlas(AtlasLayout layout, List<AtlasGenerator.AtlasEntry> entries) {
return atlas(layout, entries, directory.resolve("atlas.png"));
}

@Override
protected JsonObject entryToJson(AtlasEntry entry) {
JsonObject json = new JsonObject();
json.addProperty("name", entry.name);
json.addProperty("x", entry.x);
json.addProperty("y", entry.y);
json.addProperty("size", layout.tileSize());
return json;
}
private TestAtlas atlas(AtlasLayout layout, List<AtlasGenerator.AtlasEntry> entries, Path atlasOutputPath) {
return new TestAtlas(layout, entries, atlasOutputPath, directory.resolve("coordinates.json"));
}
}
Loading
Loading