diff --git a/docs/manual/06-geometry.md b/docs/manual/06-geometry.md index 716705d..be079ef 100644 --- a/docs/manual/06-geometry.md +++ b/docs/manual/06-geometry.md @@ -267,6 +267,8 @@ used in the level** hides unused ones. Click a model to make it the current one, Painting onto a tile at the same height **swaps its model** instead of stacking a second tile. New tiles copy the vertex colours of an existing tile with the same model. +One drag is one undo step: **Ctrl+Z** brings back all tiles a stroke painted over, erased or swapped. + > **Tip:** Prefer swapping, moving or hiding original tiles over erasing them. Object scripts can > address tiles by their number, and erasing a tile shifts the numbers of all later tiles. The > status bar warns you when you erase an original tile. @@ -326,7 +328,15 @@ change all tiles** (paint the model, every tile using it changes) or **Shared mo this tile** (the painted tile gets its own copy of the model first). Brushes have one material per face class (top, sides, bottom); tile models are painted per -polygon. Tile collision cannot be switched off – paint it 30 or hide the tile instead. +polygon. A painting drag is one undo step. + +### Tiles without collision + +To make a tile only visual, select it and tick **No collision** (Inspector, section **Build**; works +for several selected tiles at once). The built level then uses a copy of the tile's model without +collision polygons: the llama walks through the tile (a wall) or falls through it (a floor). Other +tiles of the same model keep their collision. In the editor the tile no longer shows collision +lines, and checks such as "no floor under the player start" ignore it. The layer **Invisible collision volumes** draws invisible walls, invisible blocks and colliders translucent red so you can see them while editing. diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md index 7aab96b..7ad1ca4 100644 --- a/docs/manual/07-meshes.md +++ b/docs/manual/07-meshes.md @@ -7,7 +7,7 @@ collision and lit vertex colours, and its textures are packed onto texture pages The **library** works the other way round: it saves geometry from any level (original tiles, brushes and meshes, with their textures) so you can insert it into other levels. And **Export -selection as OBJ** takes level geometry to Blender. +selection as OBJ** takes level geometry to Blender, **Export level as glTF** the whole level. ## Supported files @@ -178,6 +178,28 @@ it in Blender, then **File → Import mesh** it back (round trip). > **Note:** The exported textures are game data – fine for your own editing, please don't share > them. +## Export the level as glTF + +**File → Export level as glTF…** writes the whole level – tiles, brushes and meshes – as one glTF +2.0 file for Blender or any 3D viewer: + +* **.glb** (binary) or **.gltf** (text, everything in one file), chosen by the file type, +* the level's texture pages as PNG pictures inside the file, the game's vertex colours + (light and shade) as vertex colours, transparent and double-sided parts as such, +* unlit materials, so viewers show the baked colours like the game. + +| Option | Meaning | +|---|---| +| **Object models** | also the models of placed objects (coins, characters …) as the viewport shows them | +| **Leave out hidden items** | items hidden in the editor (**H**) are not exported | +| **Game orientation** | mirrors the level so that Blender and viewers show it the same way round as the game. Off: the same axes as the OBJ export and **Import mesh**, so the file can be imported again unchanged | + +Tiles with **Exclude from build** are left out. 1 unit is 1 cell. + +![glTF export dialog and the exported village read back by Import mesh](img/geo_gltf_export.jpg) + +> **Note:** The exported textures are game data – fine for your own use, please don't share them. + ## Tips for good meshes * Model at the right scale: 1 cell is one grid square, the llama is about 0.6 cells tall. Use diff --git a/docs/manual/19-shortcuts.md b/docs/manual/19-shortcuts.md index d8eac34..ced0d32 100644 --- a/docs/manual/19-shortcuts.md +++ b/docs/manual/19-shortcuts.md @@ -157,6 +157,7 @@ Every menu command with its shortcut and a short description. | Import texture… | | Import PNG/JPG images as textures (colour reduction, blend class, packing) | | Export level WAD… | | Build the level and save the game file (.wad) somewhere | | Export selection as OBJ… | | Mesh, UVs, vertex colours, textures (PNG) and collision for Blender | +| Export level as glTF… | | The whole level with textures and vertex colours as .glb / .gltf (Blender, viewers) | | Close editor | **Ctrl+Q** | Close the editor | ### Edit menu diff --git a/docs/manual/img/geo_gltf_export.jpg b/docs/manual/img/geo_gltf_export.jpg new file mode 100644 index 0000000..0b81f9b Binary files /dev/null and b/docs/manual/img/geo_gltf_export.jpg differ diff --git a/notes/bug-review-editor.md b/notes/bug-review-editor.md index 2808a93..fca6853 100644 --- a/notes/bug-review-editor.md +++ b/notes/bug-review-editor.md @@ -45,11 +45,13 @@ undo/history, project IO, build, play-test; property/fuzz tests; stress run of t ## Not fixed (reasons) -* Damaged-file dialogs show raw exception text ("ClassCastException …"); cosmetic, left to the UX review. -* After a failed open, the status bar keeps "Loading " (`StatusBar.progressDone` keeps the last message) – cosmetic (UX). -* `GlRenderer.dispose()` does not delete `dynVao`/`dynVbo` (only at window close) – negligible. -* `SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes until the next level (small, bounded by edits). -* Tile-paint erase strokes are one undo step per tile (`DeferredRemove` has no merge) – UX, not a defect. +* ~~Damaged-file dialogs show raw exception text~~ – fixed 2026-11-07 (edui): `ErrorDialog` (all `ctx.error` calls) shows one + plain sentence ("The project file is damaged.", "The file x does not exist.") and the exception + stack trace behind **Details**. +* ~~After a failed open, the status bar keeps "Loading "~~ – fixed 2026-11-07 (edui): a failed background task sets + " failed"; EditorStress `corrupt` now fails if the status still says "Loading…". +* ~~`GlRenderer.dispose()` does not delete `dynVao`/`dynVbo`~~ – fixed 2026-11-07 (edgeo), see editor-render.md. +* ~~`SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes~~ – fixed 2026-11-07 (edgeo): dropped every 256 removals. +* ~~Tile-paint erase strokes are one undo step per tile~~ – fixed 2026-11-07 (edgeo): `cmd.Stroke`, one step per drag. * `installTarget` treats a file with the same kind/base/level name as the previous play-test copy; two different projects with the same level name and base still share one file (rare; renaming one avoids it). * `LevelProjectSource`: when all TestLevel chapters 2..49 are taken, the 49th is used twice – practically unreachable. diff --git a/notes/editor-render.md b/notes/editor-render.md index 0a329ea..1db9526 100644 --- a/notes/editor-render.md +++ b/notes/editor-render.md @@ -277,6 +277,22 @@ behind: game look / editor). (Ranges: several agents shared the CPU.) Scene conversion from WAD 160–290 ms. On any real GPU this is far below 1 ms of GPU time; the CPU side is ~150 draw calls per frame. +## GPU resources (2026-11-07) + +* `GlRenderer.dispose()` frees everything it created: batches, texture array, pick FBO, programs and + the dynamic `dynVao`/`dynVbo` (gizmo, overlays). +* Chunks that lose their last member are deleted with their batch (before: an empty chunk kept a + `Chunk` entry until the next full rebuild). Shared per-mesh batches (`meshBatches`, objects and + tiles without vertex colours) whose mesh no instance uses any more are freed once there are more + than 256 (limit doubles with the live count) – replaced meshes used to stay until a level switch. +* `Viewport.liveGlBuffers()` counts live VAO/VBO pairs of all batches (diagnostics). + `ui.view.GlLeakCheck` (test sources, X display): 3 rounds over Village 1 / world 4 / Village 2, each + load followed by 60 rounds of tile moves, object moves, delete + undo while rendering → the count + is identical per level in every round (84 / 87 / 76), 0 with no level loaded. +* `SceneRenderer` drops the pick ids of nodes that are no longer in the scene every 256 removals + (nodes that come back by undo get a new id; selected nodes keep theirs) – `SceneRendererIdsTest`. + Tiles marked "no collision" use a copy of their model mesh without the collision overlay. + ## Known limits / next steps * Only the first vertex colour frame and the primary tile model (no alt/swap models). diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md index 12beb4f..dbf1060 100644 --- a/notes/editor-tools-geometry.md +++ b/notes/editor-tools-geometry.md @@ -62,6 +62,20 @@ height Page Up/Down. Painting onto a tile at the same height swaps its model ins copy the vertex colours of a tile with the same model. **Tile swaps** (doors/bridges, WFPC 0x10000): the Tile section shows the swap entry, sets the alternative model from the palette, removes or adds a swap; layer "Tile swap alternatives" draws the alternative model in cyan. +One drag is **one undo step** (2026-11-07): paint, erase and swap issue `cmd.Stroke` commands with +the gesture's key; `Stroke.mergeWith` concatenates them (a `Batch` only merges commands of the same +shape, `DeferredRemove`/`AddNode` never merge). The collision tool's paint/toggle strokes use the +same command. + +**No collision (visual only)** (2026-11-07): tile property `noCollision` (Inspector → Build, "No +collision"). `WadBuilder.visualOnly` replaces such tiles in the build by copies whose model (and +swap models) is a copy without collision polygons (`gen:visual:`, one per model, appended +to TRAK); other tiles of the model keep it. `Tile.worldCollisionPolygons()` is empty for them, so +`CollisionIndex`, checks, `FloorFinder`, `Ground` and the death-plane coverage ignore them; the +renderer draws them without the collision overlay. Default off = byte-identical builds. +In game (apptest :23, `NoCollisionDemo`, TestLevel 6): two walls baked from box brushes into scene +tiles, the north one visual only – the llama walks through it, the south one stops it +(`img/editor/geo_nocollision_game.jpg`, top row through, bottom row blocked). ### Collision tool (K) Turns the collision layer on. Hovering highlights the polygon (or, on brushes, the whole face) under @@ -100,6 +114,33 @@ and keeps the placement. (round trip verified: same shape and textures, `img/editor/geo_library_insert.jpg` – right the library tiles, left the re-imported OBJ as a mesh brush). +### glTF export (File → Export level as glTF…, `editor.tools.gltf`) +* `GltfExport.collect(scene, Options(objects, gameView), skip)` runs a private `SceneRenderer` on the + scene and takes exactly what the viewport draws: tile instances (scene tiles + compiled brushes and + meshes), optionally object models; `excluded` tiles and (option) hidden nodes are skipped. + Vertices are baked to world (export) space, deduplicated per primitive; one primitive per + page × blend class × double-sided, grouped into the meshes/nodes `level` and `objects`. +* Attributes: POSITION (exact min/max), NORMAL (unit; zero vertex normals → face normal; degenerate + triangles dropped), TEXCOORD_0 (page-normalised, v = 0 at the top as in glTF), COLOR_0 (u8 + normalised RGBA = engine vertex colour ×2 clamped × flat/translucent face colour, as the viewport + shader). Materials: base colour texture = page PNG (embedded bufferView), `MASK` 0.5 for pages + with cut-out pixels, `BLEND` for alpha/additive classes (glTF has no additive), `doubleSided`, + `KHR_materials_unlit` (optional extension). u16 indices up to 65535 vertices, else u32. +* `.glb` (JSON + BIN chunk) or `.gltf` (one file, buffer as base64 data URI). +* Axes: export space (y up, z = −game z) like the OBJ export and the mesh importer, so a round trip + through **Import mesh** is exact. **Game orientation** mirrors z (and winding) for viewers – see + the open handedness question below. +* Verified: `GltfExportTest` (structural validation as the Khronos validator would: GLB header and + chunk padding, view/accessor bounds, equal attribute counts, exact POSITION min/max, unit normals, + UV range, index range, PNGs decode; .gltf data URI; round trip through `MeshImport` with the same + triangle count, bounds, textures and colours; mirrored bounds; objects; excluded tiles; brush box). + The official validator (npm) could not be run here (external code not allowed). **Blender 2.9 + (local install)** imports the village .glb without complaint: meshes `level` 26 355 tris / + `objects` 9 684, 16 + 15 materials, 14 images, vertex colours and UVs + (`img/editor/geo_gltf_blender.jpg`, Workbench render); the editor's own Import mesh reads it back + (26 403 triangles, 16 materials, 15 textured, `img/editor/geo_gltf_export.jpg`). + Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB. + ### Checks (Level → Check, Build) Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**; **overlapping** identical tiles; **no floor collision under the player start** (cell-based like the @@ -156,9 +197,16 @@ between neighbouring floors at the same height; brush compiler warnings. ## Known limits * Collision materials of brushes are per face class, not per individual polygon; tile models per - polygon. Collision of tile-model polygons cannot be switched off (paint 30 or hide the tile). + polygon. Collision of a whole tile can be switched off ("No collision"), single tile-model polygons cannot. * Water and death planes are cell based: resize/move/90° only. Gaps check only unrotated box/platform/floor tops. * Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library - (see the objects agent's prefabs / object catalogue). glTF export not done (OBJ only). + (see the objects agent's prefabs / object catalogue). +* glTF export: whole level only (no "selection" variant), no collision export (the OBJ export has it), + no texture animations, additive surfaces become plain alpha blending. **Handedness**: the editor + view is left-handed like the game (editor-render.md), but the mesh importer and the OBJ/glTF + exports treat file coordinates as export space – so a right-handed tool (Blender) shows exported + levels mirrored relative to the game, and an asymmetric model imported from Blender probably + appears mirrored in game. Not verified in game; "Game orientation" in the glTF export is the + workaround for viewing. * `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256). diff --git a/notes/img/editor/geo_gltf_blender.jpg b/notes/img/editor/geo_gltf_blender.jpg new file mode 100644 index 0000000..f21eda1 Binary files /dev/null and b/notes/img/editor/geo_gltf_blender.jpg differ diff --git a/notes/img/editor/geo_gltf_export.jpg b/notes/img/editor/geo_gltf_export.jpg new file mode 100644 index 0000000..0b81f9b Binary files /dev/null and b/notes/img/editor/geo_gltf_export.jpg differ diff --git a/notes/img/editor/geo_nocollision_game.jpg b/notes/img/editor/geo_nocollision_game.jpg new file mode 100644 index 0000000..1195e97 Binary files /dev/null and b/notes/img/editor/geo_nocollision_game.jpg differ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java b/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java index c5bb1ba..36e5e83 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java @@ -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 tiles = new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList()); + visualOnly(tiles, models); tiles.addAll(gen.tiles()); List 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 tiles, List models) { + Map copies = new IdentityHashMap<>(); + Function 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 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()) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java index 16c0e9e..f4903b1 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java @@ -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. * *

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 PROPS = new Props() .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 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). */ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Stroke.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Stroke.java new file mode 100644 index 0000000..9f76258 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Stroke.java @@ -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 commands; + private final Object key; + + public Stroke(String label, Command c, Object key) { + this(label, List.of(c), key); + } + + private Stroke(String label, List commands, Object key) { + this.label = label; + this.commands = commands; + this.key = key; + } + + public List 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 l = new ArrayList<>(commands.size() + o.commands.size()); + l.addAll(commands); + l.addAll(o.commands); + return new Stroke(label, l, key); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java index 479472b..ade2fc1 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java @@ -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); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java b/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java index ccdb4b0..664135b 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java @@ -42,6 +42,8 @@ final class GlRenderer { private final Map singles = new IdentityHashMap<>(); private final Map meshBatches = new IdentityHashMap<>(); + private static final int MESH_BATCH_LIMIT = 256; + private int meshBatchLimit = MESH_BATCH_LIMIT; private final Set detached = new HashSet<>(); private Batch markerBatch; private Set 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 dirty = scene.takeDirty(); + if (dirty != null && meshBatches.size() > meshBatchLimit) { + pruneMeshBatches(); + } if (dirty == null || chunks.isEmpty() && chunkOf.isEmpty()) { Set 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 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); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java index ef88a3a..ce8127f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java @@ -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(); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java index 7c8ed21..036750d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java @@ -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)" : "")); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java index 453c623..2f2d21b 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java @@ -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 + "
"); + JLabel l = Widgets.wrappingLabel(html); l.setFont(l.getFont().deriveFont(11.5f)); l.setForeground(javax.swing.UIManager.getColor("Label.disabledForeground")); return l; diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java index 6eefdd8..bab5df7 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java @@ -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.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 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()); } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java new file mode 100644 index 0000000..7e5c75d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java @@ -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("Mirror z so that Blender and viewers show the level as the game does.
" + + "Off: the same axes as the OBJ export and the editor's mesh import."); + 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("
The textures are game data:" + + " fine for your own use, please don't share them.
"), + 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)); + } + }); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java new file mode 100644 index 0000000..3d2cc01 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java @@ -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. + * + *

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 verts = new ArrayList<>(); + final List cols = new ArrayList<>(); + final Map index = new HashMap<>(); + final List 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 prims; + final List pages; + final String name; + + Geometry(String name, List prims, List 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 imageOf = new HashMap<>(); + List images = new ArrayList<>(); + List 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 materials = new ArrayList<>(); + Map matOf = new LinkedHashMap<>(); + Map> 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 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 meshes = new ArrayList<>(); + List nodes = new ArrayList<>(); + for (Map.Entry> e : meshPrims.entrySet()) { + nodes.add(map("name", e.getKey(), "mesh", meshes.size())); + meshes.add(map("name", e.getKey(), "primitives", e.getValue())); + } + List sceneNodes = new ArrayList<>(); + for (int i = 0; i < nodes.size(); i++) { + sceneNodes.add(i); + } + byte[] bin = w.out.toByteArray(); + Map buffer = new LinkedHashMap<>(); + buffer.put("byteLength", bin.length); + if (dataUri) { + buffer.put( + "uri", + "data:application/octet-stream;base64," + + Base64.getEncoder().encodeToString(bin)); + } + Map 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 material(Prim p, Map imageOf, List pages) { + Map 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 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 map(Object... kv) { + Map 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 views = new ArrayList<>(); + final List accessors = new ArrayList<>(); + + int view(byte[] data, Integer target) { + while (out.size() % 4 != 0) { + out.write(0); + } + Map 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 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 skip) { + SceneRenderer sr = new SceneRenderer(); + sr.setScene(s); + RenderScene rs = sr.renderScene(); + Map 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 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; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/view/SceneRenderer.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/view/SceneRenderer.java index f6c61a4..12dfe3e 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/view/SceneRenderer.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/view/SceneRenderer.java @@ -70,8 +70,15 @@ public final class SceneRenderer { private final Map ids = new IdentityHashMap<>(); private final Map 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 modelMeshes = new IdentityHashMap<>(); + /** Meshes of "no collision" tiles: as modelMeshes, without the collision overlay. */ + private final Map visualMeshes = new IdentityHashMap<>(); + private final Map tileInst = new IdentityHashMap<>(); private final Map stratInst = new IdentityHashMap<>(); private final List 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 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) { diff --git a/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt b/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt index 524c7ca..418a21f 100644 --- a/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt +++ b/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt @@ -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 diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java new file mode 100644 index 0000000..df1b944 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java @@ -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 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 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]); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionTilesTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionTilesTest.java new file mode 100644 index 0000000..a799b58 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionTilesTest.java @@ -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 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)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/model/cmd/StrokeTest.java b/src/test/java/com/eliasmueller/llamamod/editor/model/cmd/StrokeTest.java new file mode 100644 index 0000000..e9b0803 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/model/cmd/StrokeTest.java @@ -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 before = new ArrayList<>(s.tiles()); + Object stroke = new Object(); + List 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); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java new file mode 100644 index 0000000..d7d597f --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java @@ -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 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); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java new file mode 100644 index 0000000..87d4334 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java @@ -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"); + } + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/view/GlLeakCheck.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/view/GlLeakCheck.java new file mode 100644 index 0000000..0e139c5 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/view/GlLeakCheck.java @@ -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 tiles = s.tiles().subList(0, 40); + List 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); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/view/SceneRendererIdsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/view/SceneRendererIdsTest.java new file mode 100644 index 0000000..bd2d64c --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/view/SceneRendererIdsTest.java @@ -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()); + } +}