editor: tile paint and collision strokes are one undo step, no collision option for tiles (visual-only model copy in the build), freed gl buffers and stale pick ids, export level as gltf/glb with textures and vertex colours

This commit is contained in:
MineTec committed 2026-11-07 11:57:22 +01:00
1 parent 59531a60b8
commit 2f22a56cec
30 files changed
+1737 -27

No files matched your search

@@ -1,5 +1,7 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
@@ -9,7 +11,10 @@ import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Wad;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* Scene → level WAD. Brushes and mesh placements are converted to tiles ({@link BrushCompiler})
@@ -48,6 +53,7 @@ public final class WadBuilder {
models.addAll(gen.models());
List<Tile> tiles =
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
visualOnly(tiles, models);
tiles.addAll(gen.tiles());
List<TexturePage> pages = new ArrayList<>(s.pages());
pages.addAll(gen.pages());
@@ -92,6 +98,46 @@ public final class WadBuilder {
return new Result(wad, st, warn);
}
/**
* Tiles marked "no collision" get a copy of their model (and swap models) without collision
* polygons, appended to models; one copy per model.
*/
static void visualOnly(List<Tile> tiles, List<TileModel> models) {
Map<TileModel, TileModel> copies = new IdentityHashMap<>();
Function<TileModel, TileModel> strip = m -> m == null
? null
: copies.computeIfAbsent(m, k -> {
TileModel c = copy(new TileModel("gen:visual:" + k.id()), k);
for (int g = 0; g < 3; g++) {
c.setRaw(TileModel.collisionProp(g), new byte[0]);
}
models.add(c);
return c;
});
for (int i = 0; i < tiles.size(); i++) {
Tile t = tiles.get(i);
if (t.noCollision() && t.model() != null) {
Tile c = copy(new Tile(t.id()), t);
c.setRaw("model", strip.apply(t.model()));
if (t.hasSwap()) {
c.setRaw("swapModel", strip.apply(t.swapModel()));
c.setRaw("swapAltModel", strip.apply(t.swapAltModel()));
}
tiles.set(i, c);
}
}
}
/** Copies every property in declaration order (later raw values such as headers survive setters). */
private static <N extends Node> N copy(N to, N from) {
for (Prop p : from.props()) {
if (p.settable()) {
to.setRaw(p.name(), from.get(p.name()));
}
}
return to;
}
private static byte[] name(Scene s, RawChunk c) {
String n = s.settings().levelName();
if (c.managed()) {
@@ -6,8 +6,8 @@ import java.util.List;
/**
* Placed tile (MAP): a {@link TileModel} at (x, y, z) in export space (cells; z ≤ 0, game z = −z)
* turned by {@code rot}/4096 turns about y. The tile belongs to grid cell (floor(x), floor(−z)); the
* engine tests collision only against the tiles of the player's cell.
* turned by {@code rot}/4096 turns about y. The tile belongs to the grid cell of its origin, see
* {@link #cellX()}; the engine tests collision only against the tiles of the player's cell.
*
* <p>Vertex colours: {@code colors} holds frames × vertexCount(model) RGBA words (0x80 = 1.0, the
* engine doubles them); byte 3 of the first word is the frame count (0/1 = one frame). A tile with
@@ -30,6 +30,7 @@ public final class Tile extends Node {
TileModel swapAltModel;
int[] altColors;
boolean excluded;
boolean noCollision;
private static final List<Prop> PROPS = new Props<Tile>()
.group("Transform")
@@ -86,6 +87,14 @@ public final class Tile extends Node {
b -> b.desc(
"the tile stays in the project but is left out of the built level (Hide only affects the"
+ " editor view)"))
.p(
"noCollision",
"No collision",
PropType.BOOL,
n -> n.noCollision,
(n, v) -> n.noCollision = v,
b -> b.desc("the built level gets this tile without collision polygons: the llama walks"
+ " through it"))
.group("Colours")
.ints("colors", "Vertex colours (frames × vertices)", n -> n.colors, (n, v) -> n.colors = v)
.p(
@@ -131,6 +140,11 @@ public final class Tile extends Node {
return excluded;
}
/** True when the tile is only visual: the build writes it with a model copy without collision. */
public boolean noCollision() {
return noCollision;
}
public double x() {
return x;
}
@@ -194,14 +208,18 @@ public final class Tile extends Node {
|| swapAltModel != null && swapAltModel.hasCollisionMaterial(material));
}
/** Grid column, like MAP's cell lists (floor(x)). */
/**
* Grid column of the MAP cell list the engine puts the tile in: the loader (FUN_0042ac50) takes
* ftol(x − 0.5), truncating; = floor(x) for the usual cell-centred tiles (x = n + 0.5), one cell
* less for a tile whose origin is in the left half of a cell.
*/
public int cellX() {
return (int) Math.floor(x);
return (int) (x - 0.5);
}
/** Grid row (floor(−z)). */
/** Grid row: −ftol(z + 0.5) (export z ≤ 0); = floor(−z) for cell-centred tiles. */
public int cellZ() {
return (int) Math.floor(-z);
return -(int) (z + 0.5);
}
/** Fixed-point placement the engine uses: stored value or derived from x, y, z, rot. */
@@ -220,9 +238,9 @@ public final class Tile extends Node {
};
}
/** Collision polygons of the model in world export space (none without a model). */
/** Collision polygons of the model in world export space (none without a model or with {@link #noCollision}). */
public List<TileModel.PlacedPolygon> worldCollisionPolygons() {
return model == null ? List.of() : model.collisionPolygons(transform());
return model == null || noCollision ? List.of() : model.collisionPolygons(transform());
}
/** Local (export space) vertex → world (export space). */
@@ -0,0 +1,64 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
/**
* One step of a paint/erase stroke: consecutive strokes with the same key are concatenated into
* one undo step (unlike {@link Batch}, whose merge needs commands of the same shape).
*/
public final class Stroke implements Command {
private final String label;
private final List<Command> commands;
private final Object key;
public Stroke(String label, Command c, Object key) {
this(label, List.of(c), key);
}
private Stroke(String label, List<Command> commands, Object key) {
this.label = label;
this.commands = commands;
this.key = key;
}
public List<Command> commands() {
return commands;
}
@Override
public String label() {
return label;
}
@Override
public void apply(Scene s) {
for (Command c : commands) {
c.apply(s);
}
}
@Override
public void revert(Scene s) {
for (int i = commands.size() - 1; i >= 0; i--) {
commands.get(i).revert(s);
}
}
@Override
public Object mergeKey() {
return key;
}
@Override
public Command mergeWith(Command next) {
if (!(next instanceof Stroke o) || key == null || !key.equals(o.key)) {
return null;
}
List<Command> l = new ArrayList<>(commands.size() + o.commands.size());
l.addAll(commands);
l.addAll(o.commands);
return new Stroke(label, l, key);
}
}
@@ -15,6 +15,8 @@ final class Batch {
static final int STRIDE = 36;
static final int LINE_STRIDE = 16;
static final int RANGES = 6;
/** Live VAO/VBO pairs of all batches (leak diagnostics, {@link Viewport#liveGlBuffers()}). */
static final java.util.concurrent.atomic.AtomicInteger LIVE = new java.util.concurrent.atomic.AtomicInteger();
int vao;
int vbo;
@@ -36,10 +38,12 @@ final class Batch {
if (vao != 0) {
glDeleteVertexArrays(vao);
glDeleteBuffers(vbo);
LIVE.decrementAndGet();
}
if (lineVao != 0) {
glDeleteVertexArrays(lineVao);
glDeleteBuffers(lineVbo);
LIVE.decrementAndGet();
}
vao = vbo = lineVao = lineVbo = 0;
}
@@ -194,6 +198,7 @@ final class Batch {
all.flip();
g.vao = glGenVertexArrays();
g.vbo = glGenBuffers();
LIVE.incrementAndGet();
glBindVertexArray(g.vao);
glBindBuffer(GL_ARRAY_BUFFER, g.vbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
@@ -207,6 +212,7 @@ final class Batch {
all.put(lines.b.flip()).put(fill.b.flip()).flip();
g.lineVao = glGenVertexArrays();
g.lineVbo = glGenBuffers();
LIVE.incrementAndGet();
glBindVertexArray(g.lineVao);
glBindBuffer(GL_ARRAY_BUFFER, g.lineVbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
@@ -42,6 +42,8 @@ final class GlRenderer {
private final Map<RenderInstance, Batch> singles = new IdentityHashMap<>();
private final Map<RenderMesh, Batch> meshBatches = new IdentityHashMap<>();
private static final int MESH_BATCH_LIMIT = 256;
private int meshBatchLimit = MESH_BATCH_LIMIT;
private final Set<RenderInstance> detached = new HashSet<>();
private Batch markerBatch;
private Set<String> markerLayers = Set.of();
@@ -161,6 +163,12 @@ final class GlRenderer {
Chunk c = chunks.get(old);
c.members.remove(i);
c.dirty = true;
if (c.members.isEmpty()) {
if (c.batch != null) {
c.batch.delete();
}
chunks.remove(old);
}
}
if (batched(i) && scene.instances.contains(i)) {
long k = key(i);
@@ -183,6 +191,9 @@ final class GlRenderer {
}
sceneVersion = scene.version();
Set<RenderInstance> dirty = scene.takeDirty();
if (dirty != null && meshBatches.size() > meshBatchLimit) {
pruneMeshBatches();
}
if (dirty == null || chunks.isEmpty() && chunkOf.isEmpty()) {
Set<RenderInstance> keep = new HashSet<>(detached);
clearAll();
@@ -213,6 +224,23 @@ final class GlRenderer {
overlayBatches.clear();
}
/** Frees shared mesh batches whose mesh no instance of the scene uses any more (replaced meshes). */
private void pruneMeshBatches() {
Set<RenderMesh> used = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
for (RenderInstance i : scene.instances) {
used.add(i.mesh);
}
meshBatches.entrySet().removeIf(e -> {
if (used.contains(e.getKey())) {
return false;
}
singles.values().removeIf(b -> b == e.getValue());
e.getValue().delete();
return true;
});
meshBatchLimit = Math.max(MESH_BATCH_LIMIT, meshBatches.size() * 2);
}
private Batch.Builder builder() {
return new Batch.Builder(whiteLayer, pageW, arraySize);
}
@@ -1015,6 +1043,12 @@ final class GlRenderer {
glDeleteRenderbuffers(pickColor);
glDeleteRenderbuffers(pickDepth);
}
if (dynVao != 0) {
glDeleteVertexArrays(dynVao);
glDeleteBuffers(dynVbo);
dynVao = dynVbo = 0;
dynCapacity = 0;
}
glDeleteProgram(meshProg);
glDeleteProgram(lineProg);
}
@@ -109,6 +109,7 @@ public final class Viewport extends JPanel {
private Overlay toolOverlay;
private Overlay toolTopOverlay;
private Overlay screenOverlay;
private volatile Overlay hudOverlay;
public Viewport() {
super(new BorderLayout());
@@ -184,6 +185,15 @@ public final class Viewport extends JPanel {
requestRender();
}
/**
* A second pixel overlay like {@link #setScreenOverlay}, for indicators that are independent of
* the active tool (e.g. a crosshair while a key is held). Null = none.
*/
public void setHudOverlay(Overlay pixels) {
hudOverlay = pixels;
requestRender();
}
/** The GL canvas (keyboard focus owner while the viewport is active; drop target). */
public Component canvas() {
return canvas;
@@ -390,6 +400,11 @@ public final class Viewport extends JPanel {
return new GlRendererStats(s.drawCalls(), s.triangles(), s.chunksDrawn(), s.chunksTotal(), s.cpuMs(), avg);
}
/** Live vertex array/buffer pairs of the renderer's batches (leak diagnostics). */
public static int liveGlBuffers() {
return Batch.LIVE.get();
}
/** Frame statistics: last frame's draw calls/triangles/chunks, CPU time and the average frame time. */
public record GlRendererStats(
int drawCalls, int triangles, int chunksDrawn, int chunksTotal, double cpuMs, double avgFrameMs) {}
@@ -746,6 +761,10 @@ public final class Viewport extends JPanel {
if (px != null && (px.lineCount() > 0 || px.triangleCount() > 0)) {
fp.toolTop = screenToWorld(px, toolTopOverlay);
}
Overlay hud = hudOverlay;
if (hud != null && (hud.lineCount() > 0 || hud.triangleCount() > 0)) {
fp.toolTop = screenToWorld(hud, fp.toolTop);
}
if (gizmo.visible()) {
fp.gizmoLines = new Overlay();
fp.gizmoTris = new Overlay();
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.model.cmd.Stroke;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
@@ -233,7 +234,7 @@ final class CollisionTool implements Tool {
}
if (!b.brushKind().transformable()) {
if (mode == Mode.PAINT && BrushMath.has(b, "mat") && b.i("mat") != material) {
ctx.execute(SetProp.of(b, b.prop("mat"), material, stroke));
ctx.execute(new Stroke("Paint collision " + material, SetProp.of(b, "mat", material), stroke));
}
return;
}
@@ -247,7 +248,7 @@ final class CollisionTool implements Tool {
String cf = b.s("collFaces");
char c = BrushKind.FACES.charAt(k);
String n = cf.indexOf(c) >= 0 ? cf.replace(String.valueOf(c), "") : BrushMath.mapFaces(cf + c, "", "");
ctx.execute(SetProp.of(b, b.prop("collFaces"), n, stroke));
ctx.execute(new Stroke("Toggle collision", SetProp.of(b, "collFaces", n), stroke));
ctx.status(b.label() + ": " + FACE_NAMES[k] + " face collision " + (cf.indexOf(c) >= 0 ? "off" : "on"));
return;
}
@@ -260,7 +261,7 @@ final class CollisionTool implements Tool {
return;
}
fm[k] = want;
ctx.execute(SetProp.of(b, b.prop("faceMats"), fm, stroke));
ctx.execute(new Stroke("Paint collision " + material, SetProp.of(b, "faceMats", fm), stroke));
ctx.status(b.label() + ": " + FACE_NAMES[k] + " face → " + GeoWidgets.materialName(material));
return;
}
@@ -299,7 +300,7 @@ final class CollisionTool implements Tool {
target,
TileModel.collisionProp(p.group()),
target.withCollisionMaterial(p.group(), p.index(), material)));
ctx.execute(new Batch("Paint collision " + material, cmds, stroke));
ctx.execute(new Stroke("Paint collision " + material, new Batch("Paint collision " + material, cmds), stroke));
ctx.status(t.label() + " polygon " + p.index() + " → " + GeoWidgets.materialName(material)
+ (shared > 1 && !copyShared ? " (all " + shared + " tiles of this model)" : ""));
}
@@ -5,6 +5,7 @@ import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
@@ -392,7 +393,7 @@ final class GeoWidgets {
}
static JLabel hint(String html) {
JLabel l = new JLabel("<html><div style='width:215px'>" + html + "</div></html>");
JLabel l = Widgets.wrappingLabel(html);
l.setFont(l.getFont().deriveFont(11.5f));
l.setForeground(javax.swing.UIManager.getColor("Label.disabledForeground"));
return l;
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.model.cmd.Stroke;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
@@ -225,9 +226,13 @@ final class TilePaintTool implements Tool {
&& t.rot() == rot * 1024) {
if (t.model() != model && !ctx.isLocked(t)) {
// a tile at this height: swap its model (keeps the tile's index for strat scripts)
ctx.execute(new Batch(
ctx.execute(new Stroke(
"Paint tiles",
List.of(SetProp.of(t, "model", model), SetProp.of(t, "colors", colours(s, model))),
new Batch(
"Paint tiles",
List.of(
SetProp.of(t, "model", model),
SetProp.of(t, "colors", colours(s, model)))),
stroke));
}
return;
@@ -241,7 +246,7 @@ final class TilePaintTool implements Tool {
t.setRaw("rot", rot * 1024);
t.setRaw("model", model);
t.setRaw("colors", colours(s, model));
ctx.execute(new Batch("Paint tiles", List.<Command>of(new AddNode(t)), stroke));
ctx.execute(new Stroke("Paint tiles", new AddNode(t), stroke));
ctx.status("Painted " + model.id() + " in cell " + key);
}
@@ -277,7 +282,7 @@ final class TilePaintTool implements Tool {
case ERASE -> {
if (t != null && !ctx.isLocked(t) && !painted.contains(t.id())) {
painted.add(t.id());
ctx.execute(new Batch("Erase tiles", List.of(Edits.delete(ctx.scene(), List.of(t))), stroke));
ctx.execute(new Stroke("Erase tiles", Edits.delete(ctx.scene(), List.of(t)), stroke));
ctx.status(
"Erased " + t.label()
+ (t.id().contains(":n")
@@ -303,7 +308,7 @@ final class TilePaintTool implements Tool {
List<Command> cmds = new ArrayList<>();
cmds.add(SetProp.of(t, "model", model));
cmds.add(SetProp.of(t, "colors", colours(ctx.scene(), model)));
ctx.execute(new Batch("Swap tile model", cmds, stroke));
ctx.execute(new Stroke("Swap tile model", new Batch("Swap tile model", cmds), stroke));
ctx.status(t.label() + " now uses " + model.id());
}
}
@@ -0,0 +1,111 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
import java.awt.BorderLayout;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JCheckBox;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.filechooser.FileNameExtensionFilter;
/** File → Export level as glTF… (whole level: tiles, brushes, meshes, optionally objects). */
public final class GltfEditorPlugin implements EditorPlugin {
@Override
public void install(EditorExtensions x) {
x.action(ActionSpec.of(
"file.exportGltf",
"File",
30,
45,
"Export level as glTF…",
"export",
null,
GltfEditorPlugin::export)
.enabledWhen(c -> c.document() != null)
.tip("The whole level with textures and vertex colours as .glb / .gltf (Blender, viewers)"));
}
static void export(EditorContext ctx) {
if (ctx.scene() == null) {
return;
}
JFileChooser fc = new JFileChooser(ctx.prefs().getString("gltf.exportDir", System.getProperty("user.home")));
FileNameExtensionFilter glb = new FileNameExtensionFilter("glTF binary (.glb)", "glb");
FileNameExtensionFilter gltf = new FileNameExtensionFilter("glTF JSON, one file (.gltf)", "gltf");
fc.addChoosableFileFilter(glb);
fc.addChoosableFileFilter(gltf);
fc.setAcceptAllFileFilterUsed(false);
fc.setFileFilter(glb);
String stem = ctx.document().title().replaceAll("[^A-Za-z0-9._-]+", "_");
fc.setSelectedFile(new File((stem.isBlank() ? "level" : stem) + ".glb"));
JCheckBox objects = new JCheckBox("Object models", ctx.prefs().getBool("gltf.objects", false));
JCheckBox hidden = new JCheckBox("Leave out hidden items", ctx.prefs().getBool("gltf.skipHidden", true));
JCheckBox mirror = new JCheckBox("Game orientation", ctx.prefs().getBool("gltf.gameView", false));
mirror.setToolTipText("<html>Mirror z so that Blender and viewers show the level as the game does.<br>"
+ "Off: the same axes as the OBJ export and the editor's mesh import.</html>");
JPanel boxes = new JPanel();
boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS));
boxes.add(objects);
boxes.add(hidden);
boxes.add(mirror);
JPanel acc = new JPanel(new BorderLayout(0, 6));
acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
acc.add(boxes, BorderLayout.NORTH);
acc.add(
new JLabel("<html><div style='width:150px'><small>The textures are game data:"
+ " fine for your own use, please don't share them.</small></div></html>"),
BorderLayout.CENTER);
fc.setAccessory(acc);
if (fc.showSaveDialog(ctx.frame()) != JFileChooser.APPROVE_OPTION) {
return;
}
Path f = fc.getSelectedFile().toPath();
String lower = f.getFileName().toString().toLowerCase(Locale.ROOT);
if (!lower.endsWith(".glb") && !lower.endsWith(".gltf")) {
f = f.resolveSibling(f.getFileName() + (fc.getFileFilter() == gltf ? ".gltf" : ".glb"));
}
boolean binary = !f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".gltf");
ctx.prefs().put("gltf.exportDir", f.getParent().toString());
ctx.prefs().put("gltf.objects", objects.isSelected());
ctx.prefs().put("gltf.skipHidden", hidden.isSelected());
ctx.prefs().put("gltf.gameView", mirror.isSelected());
GltfExport.Geometry g;
try {
g = GltfExport.collect(
ctx.scene(),
new GltfExport.Options(objects.isSelected(), mirror.isSelected()),
n -> hidden.isSelected() && ctx.isHidden(n));
} catch (RuntimeException e) {
ctx.error("Export glTF", e);
return;
}
Path out = f;
ctx.runTask(
"Exporting " + f.getFileName(),
p -> {
Files.write(out, binary ? g.glb() : g.gltf());
return Files.size(out);
},
r -> {
if (r instanceof Long size) {
ctx.status(String.format(
Locale.ROOT,
"Exported %,d triangles (%d materials) to %s (%.1f MB) – textures are game data:"
+ " personal use only",
g.triangles(),
g.primitives(),
out.getFileName(),
size / 1e6));
}
});
}
}
@@ -0,0 +1,465 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.io.Images;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import com.eliasmueller.llamamod.editor.render.RenderMesh;
import com.eliasmueller.llamamod.editor.render.RenderScene;
import com.eliasmueller.llamamod.editor.render.TexturePage;
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import org.joml.Matrix3f;
import org.joml.Vector3f;
/**
* Level geometry (tiles, compiled brushes and meshes, optionally object models) as glTF 2.0: the
* triangles the editor viewport draws, baked to world space, one primitive per texture page × blend
* class × sidedness. Textures are the level's pages as PNG, the engine's vertex colours (doubled with
* clamp, times flat/translucent face colours) are COLOR_0, materials are unlit
* (KHR_materials_unlit, optional). Coordinates are export space like the OBJ export and the mesh
* importer (y up, z = −game z, 1 unit = 1 cell); {@link Options#gameView} mirrors z instead.
*
* <p>Two steps: {@link #collect} reads the scene (EDT), {@link Geometry#glb} / {@link Geometry#gltf}
* encode the snapshot (any thread).
*/
public final class GltfExport {
private GltfExport() {}
/**
* objects: also the models of placed objects (strats) as the viewport shows them; gameView:
* mirror z so that right-handed viewers show the level as the game does (re-importing such a
* file into the editor gives mirrored geometry).
*/
public record Options(boolean objects, boolean gameView) {}
/** One glTF primitive: unique vertices + triangle indices. */
static final class Prim {
final int page;
final int blend;
final boolean doubleSided;
final String group;
final List<float[]> verts = new ArrayList<>();
final List<Integer> cols = new ArrayList<>();
final Map<VKey, Integer> index = new HashMap<>();
final List<Integer> tris = new ArrayList<>();
Prim(String group, int page, int blend, boolean doubleSided) {
this.group = group;
this.page = page;
this.blend = blend;
this.doubleSided = doubleSided;
}
int vertex(float[] v, int rgba) {
return index.computeIfAbsent(new VKey(v, rgba), k -> {
verts.add(v);
cols.add(rgba);
return verts.size() - 1;
});
}
}
/** Position, normal, uv, rgba as one hashable key. */
private record VKey(float x, float y, float z, float nx, float ny, float nz, float u, float v, int c) {
VKey(float[] a, int c) {
this(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], c);
}
}
/** Snapshot of the exported geometry: primitives and the used texture pages. */
public static final class Geometry {
final List<Prim> prims;
final List<TexturePage> pages;
final String name;
Geometry(String name, List<Prim> prims, List<TexturePage> pages) {
this.name = name;
this.prims = prims;
this.pages = pages;
}
public int triangles() {
int n = 0;
for (Prim p : prims) {
n += p.tris.size() / 3;
}
return n;
}
public int vertices() {
int n = 0;
for (Prim p : prims) {
n += p.verts.size();
}
return n;
}
public int primitives() {
return prims.size();
}
/** Binary glTF (.glb). */
public byte[] glb() {
Encoded e = encode(false);
byte[] json = pad(e.json.getBytes(StandardCharsets.UTF_8), (byte) ' ');
byte[] bin = pad(e.bin, (byte) 0);
ByteBuffer b =
ByteBuffer.allocate(12 + 8 + json.length + 8 + bin.length).order(ByteOrder.LITTLE_ENDIAN);
b.putInt(0x46546C67).putInt(2).putInt(b.capacity());
b.putInt(json.length).putInt(0x4E4F534A).put(json);
b.putInt(bin.length).putInt(0x004E4942).put(bin);
return b.array();
}
/** JSON glTF (.gltf) with the buffer embedded as a data URI (one self-contained file). */
public byte[] gltf() {
return encode(true).json.getBytes(StandardCharsets.UTF_8);
}
private Encoded encode(boolean dataUri) {
Writer w = new Writer();
Map<Integer, Integer> imageOf = new HashMap<>();
List<Object> images = new ArrayList<>();
List<Object> textures = new ArrayList<>();
for (Prim p : prims) {
if (p.page >= 0 && !imageOf.containsKey(p.page)) {
int view = w.view(png(pages.get(p.page)), null);
imageOf.put(p.page, images.size());
images.add(map("name", "page" + p.page, "bufferView", view, "mimeType", "image/png"));
textures.add(map("sampler", 0, "source", images.size() - 1));
}
}
List<Object> materials = new ArrayList<>();
Map<String, Integer> matOf = new LinkedHashMap<>();
Map<String, List<Object>> meshPrims = new LinkedHashMap<>();
for (Prim p : prims) {
String mk = p.page + "/" + p.blend + "/" + p.doubleSided;
Integer mi = matOf.get(mk);
if (mi == null) {
mi = materials.size();
matOf.put(mk, mi);
materials.add(material(p, imageOf, pages));
}
Map<String, Object> attr = new LinkedHashMap<>();
int n = p.verts.size();
ByteBuffer pos = buf(n * 12);
ByteBuffer nor = buf(n * 12);
ByteBuffer uv = buf(n * 8);
ByteBuffer col = buf(n * 4);
float[] min = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE};
float[] max = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE};
for (int vi = 0; vi < n; vi++) {
float[] v = p.verts.get(vi);
for (int k = 0; k < 3; k++) {
pos.putFloat(v[k]);
min[k] = Math.min(min[k], v[k]);
max[k] = Math.max(max[k], v[k]);
nor.putFloat(v[3 + k]);
}
uv.putFloat(v[6]).putFloat(v[7]);
int c = p.cols.get(vi);
col.put((byte) (c >>> 24))
.put((byte) (c >>> 16))
.put((byte) (c >>> 8))
.put((byte) c);
}
attr.put("POSITION", w.accessor(pos, 34962, 5126, n, "VEC3", false, min, max));
attr.put("NORMAL", w.accessor(nor, 34962, 5126, n, "VEC3", false, null, null));
if (p.page >= 0) {
attr.put("TEXCOORD_0", w.accessor(uv, 34962, 5126, n, "VEC2", false, null, null));
}
attr.put("COLOR_0", w.accessor(col, 34962, 5121, n, "VEC4", true, null, null));
boolean small = n <= 65535;
ByteBuffer idx = buf(p.tris.size() * (small ? 2 : 4));
for (int t : p.tris) {
if (small) {
idx.putShort((short) t);
} else {
idx.putInt(t);
}
}
int ia = w.accessor(idx, 34963, small ? 5123 : 5125, p.tris.size(), "SCALAR", false, null, null);
meshPrims
.computeIfAbsent(p.group, g -> new ArrayList<>())
.add(map("attributes", attr, "indices", ia, "material", mi, "mode", 4));
}
List<Object> meshes = new ArrayList<>();
List<Object> nodes = new ArrayList<>();
for (Map.Entry<String, List<Object>> e : meshPrims.entrySet()) {
nodes.add(map("name", e.getKey(), "mesh", meshes.size()));
meshes.add(map("name", e.getKey(), "primitives", e.getValue()));
}
List<Object> sceneNodes = new ArrayList<>();
for (int i = 0; i < nodes.size(); i++) {
sceneNodes.add(i);
}
byte[] bin = w.out.toByteArray();
Map<String, Object> buffer = new LinkedHashMap<>();
buffer.put("byteLength", bin.length);
if (dataUri) {
buffer.put(
"uri",
"data:application/octet-stream;base64,"
+ Base64.getEncoder().encodeToString(bin));
}
Map<String, Object> j = new LinkedHashMap<>();
j.put("asset", map("version", "2.0", "generator", "LlamaMod level editor"));
j.put("extensionsUsed", List.of("KHR_materials_unlit"));
j.put("scene", 0);
j.put("scenes", List.of(map("name", name, "nodes", sceneNodes)));
j.put("nodes", nodes);
j.put("meshes", meshes);
j.put("materials", materials);
if (!textures.isEmpty()) {
j.put("samplers", List.of(map("magFilter", 9729, "minFilter", 9729, "wrapS", 33071, "wrapT", 33071)));
j.put("textures", textures);
j.put("images", images);
}
j.put("accessors", w.accessors);
j.put("bufferViews", w.views);
j.put("buffers", List.of(buffer));
return new Encoded(Json.write(j), bin);
}
}
private record Encoded(String json, byte[] bin) {}
private static Map<String, Object> material(Prim p, Map<Integer, Integer> imageOf, List<TexturePage> pages) {
Map<String, Object> pbr = new LinkedHashMap<>();
pbr.put("baseColorFactor", List.of(1, 1, 1, 1));
if (p.page >= 0) {
pbr.put("baseColorTexture", map("index", imageOf.get(p.page)));
}
pbr.put("metallicFactor", 0);
pbr.put("roughnessFactor", 1);
Map<String, Object> m = new LinkedHashMap<>();
String blend = p.blend == RenderMesh.BLEND_ADD ? "add" : p.blend == RenderMesh.BLEND_ALPHA ? "alpha" : "opaque";
m.put("name", (p.page >= 0 ? "page" + p.page : "flat") + "_" + blend + (p.doubleSided ? "_2s" : ""));
m.put("pbrMetallicRoughness", pbr);
if (p.blend != RenderMesh.BLEND_OPAQUE) {
m.put("alphaMode", "BLEND");
} else if (p.page >= 0 && hasCutout(pages.get(p.page))) {
m.put("alphaMode", "MASK");
m.put("alphaCutoff", 0.5);
}
if (p.doubleSided) {
m.put("doubleSided", true);
}
m.put("extensions", map("KHR_materials_unlit", Map.of()));
return m;
}
private static boolean hasCutout(TexturePage p) {
for (int c : p.rgba()) {
if ((c & 0xff) < 128) {
return true;
}
}
return false;
}
private static byte[] png(TexturePage p) {
BufferedImage img = new BufferedImage(p.width(), p.height(), BufferedImage.TYPE_INT_ARGB);
int[] argb = new int[p.rgba().length];
for (int i = 0; i < argb.length; i++) {
int c = p.rgba()[i];
argb[i] = (c & 0xff) << 24 | c >>> 8;
}
img.setRGB(0, 0, p.width(), p.height(), argb, 0, p.width());
return Images.png(img);
}
private static ByteBuffer buf(int n) {
return ByteBuffer.allocate(n).order(ByteOrder.LITTLE_ENDIAN);
}
private static byte[] pad(byte[] b, byte fill) {
int n = (b.length + 3) & ~3;
if (n == b.length) {
return b;
}
byte[] o = java.util.Arrays.copyOf(b, n);
java.util.Arrays.fill(o, b.length, n, fill);
return o;
}
private static Map<String, Object> map(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
m.put((String) kv[i], kv[i + 1]);
}
return m;
}
/** Binary buffer, buffer views (4-byte aligned) and accessors. */
private static final class Writer {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<Object> views = new ArrayList<>();
final List<Object> accessors = new ArrayList<>();
int view(byte[] data, Integer target) {
while (out.size() % 4 != 0) {
out.write(0);
}
Map<String, Object> v = map("buffer", 0, "byteOffset", out.size(), "byteLength", data.length);
if (target != null) {
v.put("target", target);
}
out.writeBytes(data);
views.add(v);
return views.size() - 1;
}
int accessor(
ByteBuffer data,
int target,
int componentType,
int count,
String type,
boolean normalized,
float[] min,
float[] max) {
int v = view(data.array(), target);
Map<String, Object> a = map("bufferView", v, "componentType", componentType, "count", count, "type", type);
if (normalized) {
a.put("normalized", true);
}
if (min != null) {
a.put("min", List.of((double) min[0], (double) min[1], (double) min[2]));
a.put("max", List.of((double) max[0], (double) max[1], (double) max[2]));
}
accessors.add(a);
return accessors.size() - 1;
}
}
// ------------------------------------------------------------------ collect
/** Reads the scene as the viewport draws it; skip: nodes to leave out (e.g. hidden ones). */
public static Geometry collect(Scene s, Options o, Predicate<Node> skip) {
SceneRenderer sr = new SceneRenderer();
sr.setScene(s);
RenderScene rs = sr.renderScene();
Map<String, Prim> prims = new LinkedHashMap<>();
Vector3f p = new Vector3f();
Vector3f n = new Vector3f();
Matrix3f nm = new Matrix3f();
float[][] tri = new float[3][];
int[] rgba = new int[3];
Vector3f e1 = new Vector3f();
Vector3f e2 = new Vector3f();
for (RenderInstance ri : rs.instances) {
boolean object = ri.kind == RenderInstance.Kind.OBJECT;
if (ri.kind != RenderInstance.Kind.TILE && !(object && o.objects())) {
continue;
}
Node owner = sr.nodeOf(ri.id);
if (owner instanceof Tile t && t.excluded() || owner != null && skip.test(owner)) {
continue;
}
RenderMesh m = ri.mesh;
ri.transform.normal(nm);
String group = object ? "objects" : "level";
for (int t = 0; t < m.triangleCount(); t++) {
int fl = m.triFlags[t];
int page = m.pages[t] < rs.pages.size() ? m.pages[t] : -1;
int tc = m.triColors[t];
for (int k = 0; k < 3; k++) {
int v = m.triangles[3 * t + k];
ri.transform.transformPosition(
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]));
nm.transform(n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]));
if (o.gameView()) {
p.z = -p.z;
n.z = -n.z;
}
int c = ri.vertexColors != null
&& (fl & RenderMesh.NO_VERTEX_COLOR) == 0
&& v < ri.vertexColors.length
? doubled(ri.vertexColors[v])
: 0xffffffff;
c = mul(c, tc);
tri[k] = new float[] {p.x, p.y, p.z, n.x, n.y, n.z, m.uvs[6 * t + 2 * k], m.uvs[6 * t + 2 * k + 1]};
rgba[k] = c;
}
if (o.gameView()) {
float[] x = tri[1];
tri[1] = tri[2];
tri[2] = x;
int y = rgba[1];
rgba[1] = rgba[2];
rgba[2] = y;
}
// unit normals (glTF requires them): face normal where the vertex normal is zero
e1.set(tri[1][0] - tri[0][0], tri[1][1] - tri[0][1], tri[1][2] - tri[0][2]);
e2.set(tri[2][0] - tri[0][0], tri[2][1] - tri[0][1], tri[2][2] - tri[0][2]);
e1.cross(e2);
if (!(e1.lengthSquared() > 1e-14f)) {
continue;
}
e1.normalize();
for (float[] q : tri) {
float len = (float) Math.sqrt(q[3] * q[3] + q[4] * q[4] + q[5] * q[5]);
if (len > 1e-6f) {
q[3] /= len;
q[4] /= len;
q[5] /= len;
} else {
q[3] = e1.x;
q[4] = e1.y;
q[5] = e1.z;
}
if (page < 0) {
q[6] = 0;
q[7] = 0;
}
}
boolean dbl = (fl & RenderMesh.DOUBLE_SIDED) != 0;
int blend = m.blend[t];
Prim pr = prims.computeIfAbsent(
group + "/" + page + "/" + blend + "/" + dbl, key -> new Prim(group, page, blend, dbl));
for (int k = 0; k < 3; k++) {
pr.tris.add(pr.vertex(tri[k], rgba[k]));
}
}
}
List<Prim> l = new ArrayList<>();
for (Prim pr : prims.values()) {
if (!pr.tris.isEmpty()) {
l.add(pr);
}
}
String name = s.settings().levelName();
return new Geometry(name == null || name.isBlank() ? "level" : name, l, List.copyOf(rs.pages));
}
/** Game vertex colour (0xRRGGBBAA, 0x80 = 1.0) doubled with clamp, as the viewport does. */
static int doubled(int rgba) {
int r = Math.min(255, ((rgba >>> 24) & 0xff) * 2);
int g = Math.min(255, ((rgba >>> 16) & 0xff) * 2);
int b = Math.min(255, ((rgba >>> 8) & 0xff) * 2);
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
}
static int mul(int a, int b) {
int r = ((a >>> 24) & 0xff) * ((b >>> 24) & 0xff) / 255;
int g = ((a >>> 16) & 0xff) * ((b >>> 16) & 0xff) / 255;
int bl = ((a >>> 8) & 0xff) * ((b >>> 8) & 0xff) / 255;
int al = (a & 0xff) * (b & 0xff) / 255;
return r << 24 | g << 16 | bl << 8 | al;
}
}
@@ -70,8 +70,15 @@ public final class SceneRenderer {
private final Map<Node, Integer> ids = new IdentityHashMap<>();
private final Map<Integer, Node> nodes = new HashMap<>();
private final int[] counters = new int[32];
/** Removals since the last pick-id cleanup; ids of absent nodes are dropped every PRUNE_AFTER. */
private int removedSincePrune;
private static final int PRUNE_AFTER = 256;
private final Map<TileModel, RenderMesh> modelMeshes = new IdentityHashMap<>();
/** Meshes of "no collision" tiles: as modelMeshes, without the collision overlay. */
private final Map<TileModel, RenderMesh> visualMeshes = new IdentityHashMap<>();
private final Map<Tile, RenderInstance> tileInst = new IdentityHashMap<>();
private final Map<Strat, RenderInstance> stratInst = new IdentityHashMap<>();
private final List<RenderInstance> brushInst = new ArrayList<>();
@@ -107,6 +114,7 @@ public final class SceneRenderer {
scene = s;
ids.clear();
nodes.clear();
removedSincePrune = 0;
Arrays.fill(counters, 0);
hidden.clear();
selected.clear();
@@ -124,6 +132,9 @@ public final class SceneRenderer {
return;
}
Node n = e.node();
if (e.type() == SceneEvent.Type.REMOVED) {
removedSincePrune++;
}
switch (n.kind()) {
case TEXTURE_PAGE, TEXTURE, TEXTURE_ANIM -> dirtyTextures = true;
case RAW_CHUNK -> dirtyAll = true;
@@ -176,6 +187,7 @@ public final class SceneRenderer {
}
}
for (TileModel m : models) {
visualMeshes.remove(m);
if (scene.contains(m)) {
modelMeshes.put(m, tileMesh(m));
} else {
@@ -205,6 +217,30 @@ public final class SceneRenderer {
dirtyMarkers = false;
rebuildMarkers();
}
if (removedSincePrune >= PRUNE_AFTER) {
pruneIds();
}
}
/**
* Forgets the pick ids of nodes no longer in the scene (deleted, or undone additions). A node
* that comes back by undo/redo simply gets a new id; nothing keeps ids of absent nodes.
*/
private void pruneIds() {
removedSincePrune = 0;
ids.entrySet().removeIf(e -> {
Node n = e.getKey();
if (scene.contains(n) || selected.contains(n)) {
return false;
}
nodes.remove(e.getValue());
return true;
});
}
/** Pick ids currently assigned (for tests). */
int assignedIds() {
return ids.size();
}
// ------------------------------------------------------------ ids
@@ -340,6 +376,7 @@ public final class SceneRenderer {
rs.markers.clear();
rs.overlays.clear();
modelMeshes.clear();
visualMeshes.clear();
tileInst.clear();
stratInst.clear();
brushInst.clear();
@@ -434,6 +471,19 @@ public final class SceneRenderer {
return rm;
}
private static RenderMesh withoutOverlay(RenderMesh m) {
RenderMesh c = new RenderMesh(m.name, m.vertexCount(), m.triangleCount());
System.arraycopy(m.positions, 0, c.positions, 0, m.positions.length);
System.arraycopy(m.normals, 0, c.normals, 0, m.normals.length);
System.arraycopy(m.triangles, 0, c.triangles, 0, m.triangles.length);
System.arraycopy(m.uvs, 0, c.uvs, 0, m.uvs.length);
System.arraycopy(m.pages, 0, c.pages, 0, m.pages.length);
System.arraycopy(m.triColors, 0, c.triColors, 0, m.triColors.length);
System.arraycopy(m.triFlags, 0, c.triFlags, 0, m.triFlags.length);
System.arraycopy(m.blend, 0, c.blend, 0, m.blend.length);
return c;
}
/** Texture pages as uploaded (index = page number). */
public List<TexturePage> pages() {
return rs.pages;
@@ -454,6 +504,11 @@ public final class SceneRenderer {
mesh = tileMesh(t.model());
modelMeshes.put(t.model(), mesh);
}
if (t.noCollision()) {
RenderMesh full = mesh;
// same triangles, no collision overlay (the build drops the tile's collision)
mesh = visualMeshes.computeIfAbsent(t.model(), m -> withoutOverlay(full));
}
RenderInstance ri = old;
if (ri == null || ri.mesh != mesh) {
if (old != null) {
@@ -123,6 +123,7 @@ action.geo.deathPlane = 06-geometry.md#death-floors-and-the-death-plane
action.geo.reimport = 07-meshes.md#re-import-mesh
action.geo.libSave = 07-meshes.md#saving-to-the-library
action.geo.exportObj = 07-meshes.md#export-selection-as-obj
action.file.exportGltf = 07-meshes.md#export-the-level-as-gltf
action.geo.* = 06-geometry.md#rotating-scaling-and-mirroring
action.tex.manager = 08-textures.md#texture-manager
action.tex.replace = 08-textures.md#replacing-pixels
@@ -0,0 +1,121 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.format.Level;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* Not a unit test: test level for "no collision" tiles. A plaza; two walls baked from box brushes
* into scene tiles (like library tiles); the north wall's tiles are visual only. The llama starts
* west of the north wall facing east. Args: CD folder, output .llamalevel, chapter.
*/
public final class NoCollisionDemo {
private NoCollisionDemo() {}
static Brush box(Scene s, double x0, double z0, double x1, double z1, double y0, double y1, String top) {
Brush b = s.createBrush(BrushKind.BOX);
Map<String, Object> p = Map.of("x0", x0, "z0", z0, "x1", x1, "z1", z1, "y0", y0, "y1", y1);
p.forEach(b::setRaw);
if (top != null) {
b.setRaw("top", top);
}
new AddNode(b).apply(s);
return b;
}
/** Compiles one brush in a scratch copy of the scene and adds its tiles/models as scene nodes. */
static int bake(Scene s, Brush b, boolean noCollision) {
Scene scratch = s;
var others = new ArrayList<>(s.brushes());
others.remove(b);
for (Brush o : others) {
o.setRaw("enabled", false);
}
BrushCompiler.Output out = BrushCompiler.compile(scratch, new ArrayList<>());
for (Brush o : others) {
o.setRaw("enabled", true);
}
if (!out.pages().isEmpty()) {
throw new IllegalStateException("brush needs new texture pages");
}
Map<TileModel, TileModel> models = new IdentityHashMap<>();
for (TileModel m : out.models()) {
TileModel c = (TileModel) s.create(NodeKind.TILE_MODEL);
copy(m, c);
new AddNode(c).apply(s);
models.put(m, c);
}
for (Tile t : out.tiles()) {
Tile c = (Tile) s.create(NodeKind.TILE);
copy(t, c);
c.setRaw("model", models.get(t.model()));
c.setRaw("noCollision", noCollision);
new AddNode(c).apply(s);
}
Edits.delete(s, java.util.List.of(b)).apply(s);
return out.tiles().size();
}
static void copy(com.eliasmueller.llamamod.editor.model.Node from, com.eliasmueller.llamamod.editor.model.Node to) {
for (Prop p : from.props()) {
if (p.settable() && !p.name().equals("name")) {
Object v = from.get(p.name());
if (v != null || p.nullable()) {
to.setRaw(p.name(), v);
}
}
}
}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
Project pr = Project.create(cd, "new", Templates.DEFAULT_BASE);
Scene s = pr.scene();
s.settings().setRaw("name", "No Collision Test");
s.settings().setRaw("targetChapter", Integer.parseInt(a[2]));
box(s, 4, 4, 30, 24, -1, 0, null);
Brush north = box(s, 14, 5, 15, 12, 0, 2, null);
Brush south = box(s, 14, 15, 15, 23, 0, 2, null);
System.out.println("north wall tiles (visual only): " + bake(s, north, true));
System.out.println("south wall tiles (solid): " + bake(s, south, false));
double px = 10.5, pz = 8.5, yaw = 90;
Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw);
int k = 0;
for (Command c : b.commands()) {
if (c instanceof AddNode an && an.node() instanceof PathNode p) {
p.setRaw("x", Edits.fixed(k == 0 ? px - 8 : px));
p.setRaw("y", Edits.fixed(k == 0 ? 1.5 : 0));
p.setRaw("z", Edits.fixed(pz));
k++;
}
}
b.apply(s);
WadBuilder.Result r = WadBuilder.buildWithReport(s);
Level l = Level.parse(r.wad());
int noColl = 0;
for (var m : l.models()) {
noColl +=
m.coll.get(0).size() + m.coll.get(1).size() + m.coll.get(2).size() == 0 && !m.faces.isEmpty()
? 1
: 0;
}
System.out.println("built " + r.wad().length + " bytes, " + r.stats() + ", models with faces but no collision: "
+ noColl + ", warnings " + r.warnings());
ProjectIO.save(pr, Path.of(a[1]), cd);
System.out.println("saved " + a[1]);
}
}
@@ -0,0 +1,82 @@
package com.eliasmueller.llamamod.editor.io;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Model;
import java.util.List;
import org.junit.jupiter.api.Test;
/** "No collision (visual only)" tiles: the build omits their collision polygons (needs the CD). */
class NoCollisionTilesTest {
private static int coll(Model m) {
return m.coll.get(0).size() + m.coll.get(1).size() + m.coll.get(2).size();
}
@Test
void noCollisionTilesGetAModelCopyWithoutCollision() throws Exception {
Bases cd = Bases.fromCd(GoldenTest.cd());
Project p = Project.create(cd, "edit", "t1l1m001");
Scene s = p.scene();
byte[] original = WadBuilder.build(s);
Level before = Level.parse(original);
int nModels = before.models().size();
// two tiles sharing a model with collision, a third one of the same model stays solid
Tile a = null;
Tile b = null;
Tile solid = null;
for (Tile t : s.tiles()) {
if (t.model() == null || t.model().collisionCount() == 0) {
continue;
}
long same = s.tiles().stream().filter(o -> o.model() == t.model()).count();
if (same >= 3) {
List<Tile> l =
s.tiles().stream().filter(o -> o.model() == t.model()).toList();
a = l.get(0);
b = l.get(1);
solid = l.get(2);
break;
}
}
assertTrue(a != null, "a shared model with collision");
int ia = s.tiles().indexOf(a);
int ib = s.tiles().indexOf(b);
int is = s.tiles().indexOf(solid);
int orig = before.map().tiles.get(ia).model();
a.prop("noCollision").setRaw(a, true);
b.prop("noCollision").setRaw(b, true);
assertTrue(a.worldCollisionPolygons().isEmpty(), "editor collision follows the flag");
Level after = Level.parse(WadBuilder.build(s));
assertEquals(nModels + 1, after.models().size(), "one copy per model");
assertEquals(before.map().tiles.size(), after.map().tiles.size(), "the tile stays in the level");
int ma = after.map().tiles.get(ia).model();
assertEquals(ma, after.map().tiles.get(ib).model(), "tiles of one model share the copy");
assertNotEquals(orig, ma);
assertEquals(0, coll(after.models().get(ma)), "no collision polygons");
Model src = after.models().get(orig);
Model cp = after.models().get(ma);
assertTrue(coll(src) > 0);
assertEquals(src.verts.size(), cp.verts.size(), "same vertices");
assertEquals(src.faces.size(), cp.faces.size(), "same faces");
assertEquals(orig, after.map().tiles.get(is).model(), "other tiles keep the solid model");
assertEquals(before.map().tiles.get(ia).x(), after.map().tiles.get(ia).x());
assertArrayEquals(
before.map().tilesFixed.get(ia), after.map().tilesFixed.get(ia), "placement unchanged");
assertEquals(before.map().strats.size(), after.map().strats.size());
// saved with the project
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertEquals(2, q.scene().tiles().stream().filter(Tile::noCollision).count());
// off again: byte-identical original
a.prop("noCollision").setRaw(a, false);
b.prop("noCollision").setRaw(b, false);
assertArrayEquals(original, WadBuilder.build(s));
}
}
@@ -0,0 +1,83 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Paint/erase strokes (as issued by the tiles tool) are one undo step each. */
class StrokeTest {
private static Scene village() throws Exception {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available");
return Project.create(Bases.fromCd(Path.of(cd)), "edit", "t1l1m001").scene();
}
@Test
void eraseStrokeIsOneUndoStep() throws Exception {
Scene s = village();
History h = new History(s);
List<Tile> before = new ArrayList<>(s.tiles());
Object stroke = new Object();
List<Tile> erased = before.subList(10, 16);
for (Tile t : erased) {
h.execute(new Stroke("Erase tiles", Edits.delete(s, List.of(t)), stroke));
}
assertEquals(before.size() - erased.size(), s.tiles().size());
assertEquals(List.of("Erase tiles"), h.undoLabels());
h.undo();
assertEquals(before, s.tiles(), "all tiles back, same order");
assertFalse(h.canUndo());
h.redo();
assertEquals(before.size() - erased.size(), s.tiles().size());
for (Tile t : erased) {
assertFalse(s.contains(t));
}
// the next stroke is its own step
h.execute(new Stroke("Erase tiles", Edits.delete(s, List.of(before.get(30))), new Object()));
assertEquals(2, h.undoLabels().size());
}
@Test
void paintStrokeMixingAddsAndSwapsIsOneStep() throws Exception {
Scene s = village();
History h = new History(s);
int n = s.tiles().size();
Tile old = s.tiles().get(3);
var oldModel = old.model();
var otherModel =
s.tileModels().stream().filter(m -> m != oldModel).findFirst().orElseThrow();
Object stroke = new Object();
for (int i = 0; i < 4; i++) {
Tile t = (Tile) s.create(NodeKind.TILE);
t.setRaw("x", 1.5 + i);
t.setRaw("y", 0.0);
t.setRaw("z", -1.5);
t.setRaw("model", otherModel);
h.execute(new Stroke("Paint tiles", new AddNode(t), stroke));
if (i == 1) {
h.execute(new Stroke(
"Paint tiles",
new Batch("Paint tiles", List.of(SetProp.of(old, "model", otherModel))),
stroke));
}
}
assertEquals(n + 4, s.tiles().size());
assertTrue(old.model() == otherModel);
assertEquals(1, h.undoLabels().size());
h.undo();
assertEquals(n, s.tiles().size());
assertTrue(old.model() == oldModel);
}
}
@@ -0,0 +1,295 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.MeshImport;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
/** glTF export: structural validation of .glb/.gltf and a round trip through the mesh importer. */
class GltfExportTest {
static Bases cd() {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available (llamamod.cd)");
return Bases.fromCd(Path.of(cd));
}
/** Parsed file: JSON + binary buffer. */
record Parsed(Map<?, ?> json, byte[] bin) {}
static Parsed parseGlb(byte[] d) {
ByteBuffer b = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN);
assertEquals(0x46546C67, b.getInt(0), "magic glTF");
assertEquals(2, b.getInt(4), "version 2");
assertEquals(d.length, b.getInt(8), "total length");
int jl = b.getInt(12);
assertEquals(0x4E4F534A, b.getInt(16), "JSON chunk");
assertEquals(0, jl % 4, "JSON chunk padded");
int bo = 20 + jl;
int bl = b.getInt(bo);
assertEquals(0x004E4942, b.getInt(bo + 4), "BIN chunk");
assertEquals(0, bl % 4);
assertEquals(d.length, bo + 8 + bl, "no trailing bytes");
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(d, 20, jl, StandardCharsets.UTF_8));
return new Parsed(j, Arrays.copyOfRange(d, bo + 8, bo + 8 + bl));
}
static int num(Object o) {
return ((Number) o).intValue();
}
static List<?> list(Map<?, ?> m, String k) {
return m.get(k) instanceof List<?> l ? l : List.of();
}
static int comps(String type) {
return switch (type) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
default -> throw new AssertionError(type);
};
}
static int size(int ct) {
return switch (ct) {
case 5121 -> 1;
case 5123 -> 2;
case 5125, 5126 -> 4;
default -> throw new AssertionError("component type " + ct);
};
}
/** Checks what the glTF validator would: views, accessors, bounds, indices, normals, images. Returns triangles. */
static int validate(Parsed p) throws Exception {
Map<?, ?> j = p.json();
assertEquals("2.0", ((Map<?, ?>) j.get("asset")).get("version"));
Map<?, ?> buffer = (Map<?, ?>) list(j, "buffers").getFirst();
assertTrue(num(buffer.get("byteLength")) <= p.bin().length);
ByteBuffer bin = ByteBuffer.wrap(p.bin()).order(ByteOrder.LITTLE_ENDIAN);
List<?> views = list(j, "bufferViews");
for (Object o : views) {
Map<?, ?> v = (Map<?, ?>) o;
int off = num(v.get("byteOffset"));
assertTrue(off >= 0 && off + num(v.get("byteLength")) <= num(buffer.get("byteLength")), "view in buffer");
}
List<?> accessors = list(j, "accessors");
int triangles = 0;
for (Object mo : list(j, "meshes")) {
for (Object po : list((Map<?, ?>) mo, "primitives")) {
Map<?, ?> prim = (Map<?, ?>) po;
assertEquals(4, num(prim.get("mode")));
Map<?, ?> attr = (Map<?, ?>) prim.get("attributes");
int count = -1;
for (Map.Entry<?, ?> e : attr.entrySet()) {
Map<?, ?> a = (Map<?, ?>) accessors.get(num(e.getValue()));
int n = num(a.get("count"));
if (count < 0) {
count = n;
}
assertEquals(count, n, "all attributes have the same count");
Map<?, ?> v = (Map<?, ?>) views.get(num(a.get("bufferView")));
int c = comps((String) a.get("type"));
int ct = num(a.get("componentType"));
assertEquals(0, num(v.get("byteOffset")) % 4, "aligned");
assertTrue(n * c * size(ct) <= num(v.get("byteLength")), "accessor fits its view");
int base = num(v.get("byteOffset"));
switch ((String) e.getKey()) {
case "POSITION" -> {
List<?> min = (List<?>) a.get("min");
List<?> max = (List<?>) a.get("max");
float[] lo = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE};
float[] hi = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE};
for (int i = 0; i < n; i++) {
for (int k = 0; k < 3; k++) {
float f = bin.getFloat(base + (i * 3 + k) * 4);
assertTrue(Float.isFinite(f));
lo[k] = Math.min(lo[k], f);
hi[k] = Math.max(hi[k], f);
}
}
for (int k = 0; k < 3; k++) {
assertEquals(lo[k], ((Number) min.get(k)).floatValue(), "exact min");
assertEquals(hi[k], ((Number) max.get(k)).floatValue(), "exact max");
}
}
case "NORMAL" -> {
for (int i = 0; i < n; i++) {
double l = 0;
for (int k = 0; k < 3; k++) {
float f = bin.getFloat(base + (i * 3 + k) * 4);
l += f * f;
}
assertEquals(1, Math.sqrt(l), 1e-3, "unit normal");
}
}
case "TEXCOORD_0" -> {
for (int i = 0; i < n * 2; i++) {
float f = bin.getFloat(base + i * 4);
assertTrue(f >= -1e-4 && f <= 1.0001f, "uv on the page: " + f);
}
}
case "COLOR_0" -> {
assertEquals(5121, ct);
assertEquals(Boolean.TRUE, a.get("normalized"));
}
default -> throw new AssertionError("unexpected attribute " + e.getKey());
}
}
Map<?, ?> ia = (Map<?, ?>) accessors.get(num(prim.get("indices")));
int in = num(ia.get("count"));
assertEquals(0, in % 3);
Map<?, ?> iv = (Map<?, ?>) views.get(num(ia.get("bufferView")));
assertEquals(34963, num(iv.get("target")));
int ct = num(ia.get("componentType"));
int base = num(iv.get("byteOffset"));
for (int i = 0; i < in; i++) {
long ix = ct == 5123 ? bin.getShort(base + i * 2) & 0xffff : bin.getInt(base + i * 4) & 0xffffffffL;
assertTrue(ix < count, "index in range");
}
triangles += in / 3;
int mi = num(prim.get("material"));
assertTrue(mi < list(j, "materials").size());
}
}
for (Object mo : list(j, "materials")) {
Map<?, ?> pbr = (Map<?, ?>) ((Map<?, ?>) mo).get("pbrMetallicRoughness");
if (pbr.get("baseColorTexture") instanceof Map<?, ?> t) {
assertTrue(num(t.get("index")) < list(j, "textures").size());
}
}
for (Object io : list(j, "images")) {
Map<?, ?> img = (Map<?, ?>) io;
assertEquals("image/png", img.get("mimeType"));
Map<?, ?> v = (Map<?, ?>) views.get(num(img.get("bufferView")));
BufferedImage bi =
ImageIO.read(new ByteArrayInputStream(p.bin(), num(v.get("byteOffset")), num(v.get("byteLength"))));
assertNotNull(bi, "PNG decodes");
assertTrue(bi.getWidth() > 0 && bi.getHeight() > 0);
}
for (Object no : list(j, "nodes")) {
assertTrue(num(((Map<?, ?>) no).get("mesh")) < list(j, "meshes").size());
}
return triangles;
}
@Test
void villageExportsAsValidGlbAndGltf() throws Exception {
Project pr = Project.create(cd(), "edit", "t1l1m001");
Scene s = pr.scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
assertTrue(g.triangles() > 20000, "village triangles: " + g.triangles());
byte[] glb = g.glb();
Parsed p = parseGlb(glb);
assertEquals(g.triangles(), validate(p));
assertTrue(list(p.json(), "images").size() > 5, "texture pages");
assertTrue(list(p.json(), "materials").size() > 3);
// vertex positions cover the tiles' cells (export space: z ≤ 0)
double[] lo = {1e9, 1e9, 1e9};
double[] hi = {-1e9, -1e9, -1e9};
for (Object a : list(p.json(), "accessors")) {
Map<?, ?> m = (Map<?, ?>) a;
if (m.get("min") instanceof List<?> mn) {
List<?> mx = (List<?>) m.get("max");
for (int k = 0; k < 3; k++) {
lo[k] = Math.min(lo[k], ((Number) mn.get(k)).doubleValue());
hi[k] = Math.max(hi[k], ((Number) mx.get(k)).doubleValue());
}
}
}
List<Tile> drawn = s.tiles().stream()
.filter(t -> t.model() != null && t.model().faceCount() > 0)
.toList();
double tx0 = drawn.stream().mapToDouble(Tile::x).min().orElseThrow();
double tx1 = drawn.stream().mapToDouble(Tile::x).max().orElseThrow();
double tz0 = drawn.stream().mapToDouble(Tile::z).min().orElseThrow();
assertTrue(lo[0] < tx0 + 1 && hi[0] > tx1 - 1, "x range " + lo[0] + ".." + hi[0]);
assertTrue(lo[2] < tz0 + 1 && hi[2] <= 3, "export-space z " + lo[2] + ".." + hi[2]);
// .gltf: same content, buffer as data URI
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(g.gltf(), StandardCharsets.UTF_8));
String uri = (String) ((Map<?, ?>) list(j, "buffers").getFirst()).get("uri");
assertTrue(uri.startsWith("data:application/octet-stream;base64,"));
byte[] bin = Base64.getDecoder().decode(uri.substring(uri.indexOf(',') + 1));
assertEquals(g.triangles(), validate(new Parsed(j, bin)));
// round trip through the editor's own importer (file space = export space, game z = −z)
MeshData md = MeshImport.parse("v.glb", glb, f -> null, "y", 1);
assertEquals(g.triangles(), md.triangleCount());
double[] b = md.bounds();
assertEquals(lo[0], b[0], 1e-3);
assertEquals(hi[0], b[3], 1e-3);
assertEquals(-hi[2], b[2], 1e-3);
assertEquals(-lo[2], b[5], 1e-3);
assertTrue(md.materialList().stream().filter(m -> m.image() != null).count() > 5, "textures read back");
assertNotNull(md.colors(), "vertex colours read back");
// game orientation: z mirrored, same triangles
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
assertEquals(g.triangles(), m.triangles());
double[] mb = MeshImport.parse("m.glb", m.glb(), f -> null, "y", 1).bounds();
assertEquals(-b[5], mb[2], 1e-3);
assertEquals(-b[2], mb[5], 1e-3);
assertEquals(b[0], mb[0], 1e-3);
GltfExport.Geometry o = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
assertTrue(o.triangles() > g.triangles(), "objects add triangles");
assertEquals(o.triangles(), validate(parseGlb(o.glb())));
}
@Test
void brushesAndExcludedTiles() throws Exception {
Scene s = Project.create(cd(), "new", "t1l1m001").scene();
Edits.addBrush(s, BrushKind.BOX, Map.of("x0", 10.0, "z0", 10.0, "x1", 12.0, "z1", 13.0, "y0", 0.0, "y1", 1.0))
.apply(s);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
Parsed p = parseGlb(g.glb());
assertEquals(g.triangles(), validate(p));
assertTrue(g.triangles() >= 10, "box faces: " + g.triangles());
MeshData md = MeshImport.parse("b.glb", g.glb(), f -> null, "y", 1);
double[] b = md.bounds();
assertEquals(10, b[0], 1e-3);
assertEquals(12, b[3], 1e-3);
assertEquals(1, b[4], 1e-3);
// hidden brush left out
GltfExport.Geometry none = GltfExport.collect(s, new GltfExport.Options(false, false), n -> true);
assertEquals(0, none.triangles());
}
@Test
void excludedTilesAreLeftOut() throws Exception {
Scene s = Project.create(cd(), "edit", "t1l1m001").scene();
int all = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
Tile t = s.tiles().stream()
.filter(x -> x.model() != null && x.model().faceCount() > 4)
.findFirst()
.orElseThrow();
t.prop("excluded").setRaw(t, true);
int less = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
assertTrue(less < all && less >= all - t.model().faceCount(), all + " → " + less);
}
}
@@ -0,0 +1,27 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes sample exports for external validation: GltfSample CD OUTDIR level... (not a unit test). */
public final class GltfSample {
private GltfSample() {}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
Path out = Files.createDirectories(Path.of(a[1]));
for (int i = 2; i < a.length; i++) {
Scene s = Project.create(cd, "edit", a[i]).scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
Files.write(out.resolve(a[i] + ".glb"), g.glb());
Files.write(out.resolve(a[i] + ".gltf"), g.gltf());
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
Files.write(out.resolve(a[i] + "_game.glb"), m.glb());
System.out.println(a[i] + ": " + g.triangles() + " triangles, " + g.vertices() + " vertices, "
+ g.primitives() + " primitives");
}
}
}
@@ -0,0 +1,85 @@
package com.eliasmueller.llamamod.editor.ui.view;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.render.Viewport;
import java.awt.Dimension;
import java.nio.file.Path;
import java.util.List;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
/**
* Not a unit test: on an X display, switches levels and edits while rendering and prints the live
* GL buffer count (should return to the same value per level). Args: CD folder, levels...
*/
public final class GlLeakCheck {
private GlLeakCheck() {}
static void edt(Runnable r) throws Exception {
SwingUtilities.invokeAndWait(r);
}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
SceneRenderer sr = new SceneRenderer();
Viewport vp = new Viewport();
edt(() -> {
JFrame f = new JFrame("leak check");
vp.setPreferredSize(new Dimension(800, 500));
f.add(vp);
f.pack();
f.setVisible(true);
vp.setScene(sr.renderScene());
});
Thread.sleep(2000);
for (int round = 0; round < 3; round++) {
for (int i = 1; i < a.length; i++) {
Scene s = Project.create(cd, "edit", a[i]).scene();
edt(() -> {
sr.setScene(s);
vp.frameAll();
vp.renderNow();
});
Thread.sleep(300);
edt(vp::renderNow);
int afterLoad = Viewport.liveGlBuffers();
// edits: move tiles and objects, delete/undo, while rendering
History h = new History(s);
s.addListener(sr::sceneChanged);
List<Tile> tiles = s.tiles().subList(0, 40);
List<Strat> strats = s.strats();
for (int k = 0; k < 60; k++) {
int kk = k;
edt(() -> {
h.execute(Edits.translate(tiles, kk % 2 == 0 ? 1 : -1, 0, 0, null));
if (!strats.isEmpty()) {
h.execute(Edits.translate(List.of(strats.get(kk % strats.size())), 0, 0.1, 0, null));
}
h.execute(Edits.delete(s, List.of(tiles.get(kk % tiles.size()))));
h.undo();
sr.flush();
vp.renderNow();
});
}
edt(vp::renderNow);
System.out.printf(
"round %d %s: after load %d, after 60 edit rounds %d%n",
round, a[i], afterLoad, Viewport.liveGlBuffers());
}
}
edt(() -> {
sr.setScene(null);
vp.renderNow();
});
Thread.sleep(300);
edt(vp::renderNow);
System.out.println("no level: " + Viewport.liveGlBuffers());
System.exit(0);
}
}
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.editor.ui.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Pick ids of deleted nodes are dropped (bounded); "no collision" tiles have no collision overlay. */
class SceneRendererIdsTest {
private static Scene village() throws Exception {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available");
return Project.create(Bases.fromCd(Path.of(cd)), "edit", "t1l1m001").scene();
}
@Test
void idsOfDeletedNodesAreDropped() throws Exception {
Scene s = village();
SceneRenderer r = new SceneRenderer();
r.setScene(s);
s.addListener(r::sceneChanged);
History h = new History(s);
int start = r.assignedIds();
Tile keep = s.tiles().get(7);
int keepId = r.idOf(keep);
for (int i = 0; i < 1000; i++) {
Tile t = (Tile) s.create(NodeKind.TILE);
t.setRaw("x", 3.5);
t.setRaw("y", 0.0);
t.setRaw("z", -3.5);
t.setRaw("model", keep.model());
h.execute(new AddNode(t));
r.flush();
h.execute(Edits.delete(s, List.of(t)));
r.flush();
}
assertTrue(r.assignedIds() < start + 300, "bounded: " + r.assignedIds() + " vs " + start);
assertEquals(keepId, r.idOf(keep), "ids of live nodes are stable");
assertEquals(keep, r.nodeOf(keepId));
// a deleted tile that comes back by undo gets a working id again
Tile victim = s.tiles().get(9);
int old = r.idOf(victim);
for (int i = 0; i < 300; i++) {
h.execute(Edits.delete(s, List.of(victim)));
r.flush();
h.undo();
r.flush();
}
int now = r.idOf(victim);
assertNotEquals(0, now);
assertEquals(victim, r.nodeOf(now));
RenderInstance ri = r.renderScene().instance(now);
assertTrue(ri != null && ri.visible);
if (now != old) {
assertNull(r.nodeOf(old), "old id forgotten");
}
}
@Test
void noCollisionTilesHaveNoCollisionOverlay() throws Exception {
Scene s = village();
SceneRenderer r = new SceneRenderer();
r.setScene(s);
s.addListener(r::sceneChanged);
Tile t = s.tiles().stream()
.filter(x -> x.model() != null && x.model().collisionCount() > 0)
.findFirst()
.orElseThrow();
RenderInstance ri = r.renderScene().instance(r.idOf(t));
assertTrue(ri.mesh.overlay != null);
new History(s).execute(com.eliasmueller.llamamod.editor.model.cmd.SetProp.of(t, "noCollision", true));
r.flush();
RenderInstance after = r.renderScene().instance(r.idOf(t));
assertNull(after.mesh.overlay);
assertEquals(ri.mesh.triangleCount(), after.mesh.triangleCount());
}
}