diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md index 400d364..b204c67 100644 --- a/docs/manual/07-meshes.md +++ b/docs/manual/07-meshes.md @@ -7,7 +7,8 @@ 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, **Export level as glTF** the whole level. +selection as OBJ** takes level geometry to Blender, **Export level as glTF** the whole level (or +the selection). ## Supported files @@ -189,8 +190,8 @@ unchanged: edit it in Blender, then **File → Import mesh** it back (round trip ## 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: +**File → Export level as glTF…** writes the whole level (or only the selection) – 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 @@ -199,13 +200,15 @@ unchanged: edit it in Blender, then **File → Import mesh** it back (round trip | Option | Meaning | |---|---| +| **Selection only** | only the selected tiles, brushes, meshes (and objects, with **Object models**) | | **Object models** | also the models of placed objects (coins, characters …) as the viewport shows them | +| **Collision as separate mesh** | the collision polygons as an extra node **collision**, untextured, one colour per collision material (grey 0 normal, green 16 exit floor, red 30 no floor / death) – handy to check in Blender where the llama can walk | | **Leave out hidden items** | items hidden in the editor (**H**) are not exported | Tiles with **Exclude from build** are left out. 1 unit is 1 cell. Viewers show the level the same way round as the game, and **Import mesh** reads the file back unchanged. -![glTF export dialog and the exported village read back by Import mesh](img/geo_gltf_export.jpg) +![glTF export dialog and the exported village (with the collision node, death floor in red) 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. diff --git a/docs/manual/19-shortcuts.md b/docs/manual/19-shortcuts.md index ced0d32..1a5a514 100644 --- a/docs/manual/19-shortcuts.md +++ b/docs/manual/19-shortcuts.md @@ -157,7 +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) | +| Export level as glTF… | | The level (or the selection) 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 index 634637a..21a12fb 100644 Binary files a/docs/manual/img/geo_gltf_export.jpg and b/docs/manual/img/geo_gltf_export.jpg differ diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md index 2b8b409..ad993c4 100644 --- a/notes/editor-tools-geometry.md +++ b/notes/editor-tools-geometry.md @@ -88,7 +88,9 @@ Invisible collision volumes (Invisible wall/block, colliders) are drawn transluc "Invisible collision volumes"). ### Mesh import (File → Import mesh) -File chooser, then a dialog: rotatable preview (shown as in game) with a 1-cell grid and a llama-sized marker, source +File chooser, then a dialog: rotatable preview (shown as in game; flat shading with a small z-buffer at +twice the panel size, `PreviewRaster` – before 2026-11-07 the triangles were sorted by centroid and hidden +faces showed as slivers) with a 1-cell grid and a llama-sized marker, source info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground, texture size and UV precision, double-sided, lit, collision **from the mesh / simplified invisible box / none** + material, warnings (densest cell vs. the 8000-vertex limit, larger than the grid). @@ -117,9 +119,19 @@ and keeps the placement. 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), skip)` runs a private `SceneRenderer` on the +* `GltfExport.collect(scene, Options(objects, collision, only), 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. +* **Selection only** (2026-11-07): `only` = the selected nodes; an instance is exported when its owner + (tile, brush that the compiled tile belongs to, object) is in it. Nothing exportable → status message, + no file. +* **Collision as separate mesh** (2026-11-07): mesh/node `collision` with one primitive per collision + material (`collision_`, unlit base colour: 0 grey, 16 green, 30 red, others by hue, double-sided, + no UVs). Polygons of scene tiles (`Tile.worldCollisionPolygons`, so "No collision" tiles have none) and of + the compiled brush tiles (`SceneRenderer.brushOutput()`, owner = the brush instance in the same order), + fan-triangulated in game space and wound like the polygon's own plane normal (floors face +y). + `GltfExportTest.selectionOnlyAndCollisionMesh` / `collisionFloorsFaceUp`: validation, box bounds of both + nodes, a single tile's collision ≤ its polygons, round trip through Import mesh, outward box. 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 @@ -140,7 +152,9 @@ and keeps the placement. (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`). + (26 403 triangles, 16 materials, 15 textured). `img/editor/geo_gltf_export.jpg` (retaken 2026-11-07): export + dialog with the new options and the village exported with **Collision as separate mesh** read back + (45 558 triangles, 25 materials; red = material 30 under the islands), z-buffered preview. Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB. ### Handedness of files (fix 2026-11-07) @@ -245,7 +259,7 @@ between neighbouring floors at the same height; brush compiler warnings. 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: whole level only (no "selection" variant), no collision export (the OBJ export has it), - no texture animations, additive surfaces become plain alpha blending. (Handedness: fixed, see +* glTF export: no texture animations, additive surfaces become plain alpha blending; the collision node + has no tile-swap alternatives. (Handedness: fixed, see "Handedness of files" above.) * `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256). diff --git a/notes/editor-ui.md b/notes/editor-ui.md index 566dbb9..1026a8f 100644 --- a/notes/editor-ui.md +++ b/notes/editor-ui.md @@ -42,6 +42,13 @@ actions (AWT canvases do not get Swing key bindings). "Coin 3", status bar hover "Coin 3"/"Tile 41, 66 · Model 87", asset tab "Model 0"); inspector button rows wrap without clipping in a narrow dock (`ui_waypoint_buttons_narrow.jpg`); long property labels wrap (`ui_inspector_label_wrap.jpg`). +* Narrow inspector (2026-11-07, `ui_inspector_narrow.jpg`): Position/Rotation X/Y/Z use `AxisLayout` + (one row while each cell gets ≥ 66 px, else stacked one per row; also the brush rotation spinners); + reference buttons (`InspectorPanel.RefButton`) show the longest name that fits ("Waypoint 5 (chain 3)" → + "Waypoint 5" → "#5", other names drop a "(…)" suffix, then "…"), full name + id in the tooltip. +* Tool status and check texts use `NodeNames` too (geometry, tile paint, collision, texture paint, + lights, brush-outside-grid check, "Placed Model 12 in cell …"); the outliner filter applies the + whole-number rule to the old label and the id as well ("tile 3" no longer finds x = 39). ## Architecture @@ -126,6 +133,7 @@ same id again replaces (panels, tools, actions, asset tabs, layers). | `LevelCheck` | adds `Problem.error/warning/info(msg, node)`; errors block Build/Play | | `Icons` | vector icons by name; `Icons.register(name, painter)` for your own (16×16 Java2D); `Icons.swatch(colour, w, h)` | | `NodeNames` | `NodeNames.name(node)` (one node) or `NodeNames.of(scene)` (index for many: `nameOf`, `objectName`, `waypointName`, `chainStarts`) – use it instead of `Node.label()` in UI texts; tooltips may show `node.id()` | +| `AxisLayout` | public layout for X/Y/Z cells: one row, or stacked when a cell would be narrower than its minimum (`new AxisLayout(AxisLayout.CELL_MIN)`) | | `WrapLayout` | public `FlowLayout` that grows to more rows in a narrow container (inspector button rows: `ParamsSection.compact` switches a FlowLayout bar to it) | | `Widgets`, `util.SwingListeners` | `Widgets.button(text, icon, tip, run)`, `Widgets.listCell(c, list, selected)`, `Widgets.wrappingLabel(html)` (help text that wraps at the dock width – use it instead of ``); `onTextChange`, `onMenuOpen` | | `ErrorDialog` | `ctx.error(title, t)` uses it: plain sentence (`ErrorDialog.plain(t)`, no exception class names) + collapsible **Details** (stack trace) | diff --git a/notes/img/editor/geo_gltf_export.jpg b/notes/img/editor/geo_gltf_export.jpg index 634637a..21a12fb 100644 Binary files a/notes/img/editor/geo_gltf_export.jpg and b/notes/img/editor/geo_gltf_export.jpg differ diff --git a/notes/img/editor/ui_inspector_narrow.jpg b/notes/img/editor/ui_inspector_narrow.jpg new file mode 100644 index 0000000..187cd49 Binary files /dev/null and b/notes/img/editor/ui_inspector_narrow.jpg differ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java index 62e1c77..5e64f97 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.Tile; import com.eliasmueller.llamamod.editor.model.TileModel; import com.eliasmueller.llamamod.editor.ui.LevelCheck; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Problem; import java.util.List; import java.util.Locale; @@ -224,18 +225,20 @@ final class EnvChecks implements LevelCheck { } static void lights(Scene s, List out) { + NodeNames names = NodeNames.of(s); for (Light l : s.lights()) { + String name = names.nameOf(l); if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { if (l.rangeB() <= 0) { - out.add(Problem.warning(l.label() + ": falloff radius 0, the light has no effect", l)); + out.add(Problem.warning(name + ": falloff radius 0, the light has no effect", l)); } else if (l.rangeB() < l.a()) { - out.add(Problem.info(l.label() + ": falloff radius smaller than the full radius", l)); + out.add(Problem.info(name + ": falloff radius smaller than the full radius", l)); } } else if (l.type() != Light.DIRECTIONAL) { - out.add(Problem.info(l.label() + ": type " + l.type() + " is ignored by the engine", l)); + out.add(Problem.info(name + ": type " + l.type() + " is ignored by the engine", l)); } if (l.r() == 0 && l.g() == 0 && l.b() == 0) { - out.add(Problem.info(l.label() + ": colour is black (no effect)", l)); + out.add(Problem.info(name + ": colour is black (no effect)", l)); } } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java index 6be82e8..5fca9f4 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.Tile; import com.eliasmueller.llamamod.editor.render.Marker; import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.RenderContributor; import java.awt.Color; @@ -151,7 +152,7 @@ final class EnvOverlays { Marker m = b.marker(z, (float) tx, (float) ty + 0.1f, (float) tz, rgba(TARGET, 255)); m.shape = Marker.Shape.DIAMOND; m.size = 0.09f; - m.label = "target of " + z.label(); + m.label = "target of " + NodeNames.name(z); } } }; diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java index 7148d67..75c55e2 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java @@ -13,6 +13,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorPrefs; import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.Icons; import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Tool; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import com.eliasmueller.llamamod.editor.ui.tools.TransformSupport; @@ -574,7 +575,7 @@ final class BrushTool implements Tool { Brush target = BrushMath.apply(hSnap, BrushMath.drag(hSnap, hHandle.id(), amount)); Map d = BrushMath.diff(hBrush, target); if (!d.isEmpty()) { - ctx.execute(Edits.set("Resize " + hBrush.label(), Map.of(hBrush, d), mergeKey)); + ctx.execute(Edits.set("Resize " + NodeNames.name(hBrush), Map.of(hBrush, d), mergeKey)); } double[] bb = hBrush.localBounds(); ctx.status(hHandle.tip() + ": " + fmt(bb[3] - bb[0]) + " × " + fmt(bb[5] - bb[2]) + " × " + fmt(bb[4] - bb[1])); 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 036750d..9c95462 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 @@ -16,6 +16,7 @@ import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.Icons; import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Tool; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import java.awt.BorderLayout; @@ -163,7 +164,7 @@ final class CollisionTool implements Tool { String mat = "material " + GeoWidgets.materialName(p.material()); if (owner instanceof Brush b) { String face = b.brushKind().transformable() ? ", " + FACE_NAMES[faceOf(b, p.normal())] + " face" : ""; - return b.label() + face + " – " + mat; + return NodeNames.name(b) + face + " – " + mat; } Tile t = p.tile(); int shared = 0; @@ -172,7 +173,7 @@ final class CollisionTool implements Tool { shared++; } } - return t.label() + " polygon " + p.index() + " – " + mat + return NodeNames.name(t) + ", polygon " + p.index() + " – " + mat + (shared > 1 ? " (model shared by " + shared + " tiles)" : ""); } @@ -249,7 +250,8 @@ final class CollisionTool implements Tool { char c = BrushKind.FACES.charAt(k); String n = cf.indexOf(c) >= 0 ? cf.replace(String.valueOf(c), "") : BrushMath.mapFaces(cf + c, "", ""); 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")); + ctx.status(NodeNames.name(b) + ": " + FACE_NAMES[k] + " face collision " + + (cf.indexOf(c) >= 0 ? "off" : "on")); return; } int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 @@ -262,7 +264,7 @@ final class CollisionTool implements Tool { } fm[k] = want; ctx.execute(new Stroke("Paint collision " + material, SetProp.of(b, "faceMats", fm), stroke)); - ctx.status(b.label() + ": " + FACE_NAMES[k] + " face → " + GeoWidgets.materialName(material)); + ctx.status(NodeNames.name(b) + ": " + FACE_NAMES[k] + " face → " + GeoWidgets.materialName(material)); return; } if (p.generated()) { @@ -301,7 +303,7 @@ final class CollisionTool implements Tool { TileModel.collisionProp(p.group()), target.withCollisionMaterial(p.group(), p.index(), material))); ctx.execute(new Stroke("Paint collision " + material, new Batch("Paint collision " + material, cmds), stroke)); - ctx.status(t.label() + " polygon " + p.index() + " → " + GeoWidgets.materialName(material) + ctx.status(NodeNames.name(t) + ", 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/GeometryActions.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java index bfbd810..e0b0a19 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java @@ -14,6 +14,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.model.cmd.RemoveNode; import com.eliasmueller.llamamod.editor.model.cmd.SetProp; import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.NodeTransformer; import com.eliasmueller.llamamod.editor.ui.Snapping; import com.eliasmueller.llamamod.editor.ui.tools.EditOps; @@ -138,7 +139,7 @@ final class GeometryActions { if (!cmds.isEmpty()) { ctx.execute(new Batch("Align " + AXES[axis], cmds)); } - ctx.status("Aligned " + (l.size() - 1) + " item(s) on " + AXES[axis] + " to " + ref.label()); + ctx.status("Aligned " + (l.size() - 1) + " item(s) on " + AXES[axis] + " to " + NodeNames.name(ref)); } static double value(double[] b, int axis, int mode) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java index 1bc522a..d43c4ab 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.StratCatalog; import com.eliasmueller.llamamod.editor.model.Tile; import com.eliasmueller.llamamod.editor.model.TileModel; import com.eliasmueller.llamamod.editor.ui.LevelCheck; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Problem; import java.util.ArrayList; import java.util.HashMap; @@ -27,12 +28,13 @@ final class GeometryChecks implements LevelCheck { @Override public void check(Scene s, List out) { int w = s.settings().width(), h = s.settings().height(); + NodeNames names = NodeNames.of(s); // tiles outside the grid int n = 0; for (Tile t : s.tiles()) { if (t.cellX() < 0 || t.cellZ() < 0 || t.cellX() >= w || t.cellZ() >= h) { if (n++ < MAX_EACH) { - out.add(Problem.error(t.label() + " lies outside the " + w + "×" + h + " grid", t)); + out.add(Problem.error(names.nameOf(t) + " lies outside the " + w + "×" + h + " grid", t)); } } } @@ -47,8 +49,8 @@ final class GeometryChecks implements LevelCheck { for (TileModel m : s.tileModels()) { if (m.vertexCount() > MAX_VERTICES) { out.add(Problem.error( - m.label() + " has " + m.vertexCount() + " vertices – the engine allows at most " + MAX_VERTICES - + " per tile model", + NodeNames.modelName(m) + " has " + m.vertexCount() + " vertices – the engine allows at most " + + MAX_VERTICES + " per tile model", firstUse.getOrDefault(m, null))); } } @@ -80,7 +82,7 @@ final class GeometryChecks implements LevelCheck { Tile o = seen.putIfAbsent(key, t); if (o != null && n++ < MAX_EACH) { out.add(Problem.warning( - t.label() + " overlaps " + o.id() + " exactly (same model, cell, height and" + names.nameOf(t) + " overlaps another tile exactly (same model, cell, height and" + " rotation) – z-fighting and double collision", t)); } @@ -170,6 +172,7 @@ final class GeometryChecks implements LevelCheck { /** Neighbouring floors at the same height with a narrow gap between them (the llama trips into it). */ static void gaps(Scene s, List out) { + NodeNames names = NodeNames.of(s); List l = new ArrayList<>(); List tops = new ArrayList<>(); for (Brush b : s.brushes()) { @@ -197,7 +200,7 @@ final class GeometryChecks implements LevelCheck { if (gap > 0 && n++ < MAX_EACH) { out.add(Problem.warning( "Gap of " + BrushTool.fmt(gap) + " between " - + l.get(i).label() + " and " + l.get(k).label() + + names.nameOf(l.get(i)) + " and " + names.nameOf(l.get(k)) + " – close it (drag a handle) or make it a real jump", l.get(k))); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java index ecfbc73..b36990f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java @@ -14,6 +14,7 @@ import com.eliasmueller.llamamod.editor.render.Legend; import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.RenderContributor; import com.eliasmueller.llamamod.editor.ui.Widgets; import java.awt.GridBagConstraints; @@ -96,13 +97,13 @@ final class GeometrySections { Brush c = BrushMath.apply(b, Map.of("rotation", nr)); var p1 = BrushTransformer.pos(c); BrushMath.translate(c, p0.x - p1.x, p0.y - p1.y, p0.z - p1.z); - ctx.execute(Edits.set("Rotate " + b.label(), Map.of(b, BrushMath.diff(b, c)), null)); + ctx.execute(Edits.set("Rotate " + NodeNames.name(b), Map.of(b, BrushMath.diff(b, c)), null)); } }; for (JSpinner s : List.of(rx, ry, rz)) { s.addChangeListener(e -> apply.run()); } - JPanel rp = new JPanel(new java.awt.GridLayout(1, 3, 4, 0)); + JPanel rp = new JPanel(new com.eliasmueller.llamamod.editor.ui.AxisLayout(72)); rp.setOpaque(false); rp.add(rx); rp.add(ry); @@ -344,7 +345,7 @@ final class GeometrySections { } private static String id(TileModel m) { - return m == null ? "–" : m.id(); + return m == null ? "–" : NodeNames.modelName(m); } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java index c5a031f..20ff4bd 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java @@ -13,7 +13,6 @@ import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Graphics; import java.awt.Graphics2D; -import java.awt.Polygon; import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -287,7 +286,7 @@ final class MeshImportDialog extends JDialog { // ------------------------------------------------------------------ preview - /** Software preview: flat-shaded triangles, painter's algorithm, orbit by dragging. */ + /** Software preview: flat-shaded triangles with a z-buffer ({@link PreviewRaster}), orbit by dragging. */ static final class Preview extends JPanel { private MeshData mesh; /** Starts looking down at the model's front (+z, as Blender's front view shows it). */ @@ -392,17 +391,12 @@ final class MeshImportDialog extends JDialog { line(g, -n, gy, i, n, gy, i, ct, st, cyw, syw, cp, sp, sc, w2, h2, false); } int nt = mesh.triangleCount(); - Integer[] order = new Integer[nt]; - double[] depth = new double[nt]; int[] tri = mesh.triangles(); - for (int t = 0; t < nt; t++) { - order[t] = t; - depth[t] = sd[tri[3 * t]] + sd[tri[3 * t + 1]] + sd[tri[3 * t + 2]]; - } - java.util.Arrays.sort(order, (a, c) -> Double.compare(depth[c], depth[a])); - int limit = Math.min(nt, 60000); - for (int i = nt - limit; i < nt; i++) { - int t = order[i]; + // z-buffer at twice the panel size, scaled down smoothly (a little anti-aliasing) + int ss = 2; + PreviewRaster zb = new PreviewRaster(getWidth() * ss, getHeight() * ss); + boolean edges = nt < 3000; + for (int t = 0; t < Math.min(nt, 200000); t++) { int a = tri[3 * t], bb = tri[3 * t + 1], c = tri[3 * t + 2]; double[] u = Lin3.sub(cam[bb], cam[a]), v = Lin3.sub(cam[c], cam[a]); double[] nn = {u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]}; @@ -410,20 +404,24 @@ final class MeshImportDialog extends JDialog { double shade = ln < 1e-12 ? 0.6 : 0.35 + 0.65 * Math.abs((nn[0] * 0.3 + nn[1] * 0.8 - nn[2] * 0.5) / ln / 1.0); Color base = colour(mesh.materials()[t]); - Color col = new Color( - (int) Math.min(255, base.getRed() * shade), (int) Math.min(255, base.getGreen() * shade), (int) - Math.min(255, base.getBlue() * shade)); - Polygon p = new Polygon( - new int[] {(int) sx[a], (int) sx[bb], (int) sx[c]}, - new int[] {(int) sy[a], (int) sy[bb], (int) sy[c]}, - 3); - g.setColor(col); - g.fillPolygon(p); - if (nt < 3000) { - g.setColor(new Color(0, 0, 0, 40)); - g.drawPolygon(p); - } + int rgb = (int) Math.min(255, base.getRed() * shade) << 16 + | (int) Math.min(255, base.getGreen() * shade) << 8 + | (int) Math.min(255, base.getBlue() * shade); + zb.triangle( + sx[a] * ss, + sy[a] * ss, + sd[a], + sx[bb] * ss, + sy[bb] * ss, + sd[bb], + sx[c] * ss, + sy[c] * ss, + sd[c], + rgb, + edges); } + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(zb.image(), 0, 0, getWidth(), getHeight(), null); // llama for scale: 0.6 cells tall g.setStroke(new BasicStroke(2f)); g.setColor(new Color(255, 170, 60, 200)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRaster.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRaster.java new file mode 100644 index 0000000..b4e1329 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRaster.java @@ -0,0 +1,91 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import java.awt.image.BufferedImage; +import java.util.Arrays; + +/** + * Tiny z-buffer for the mesh import preview: flat triangles in screen space (smaller depth = nearer), optional + * darkened edges. Unlike sorting by centroid it never shows hidden faces in front of nearer ones. + */ +final class PreviewRaster { + private final int w, h; + private final float[] depth; + private final BufferedImage img; + private final int[] px; + + PreviewRaster(int w, int h) { + this.w = Math.max(1, w); + this.h = Math.max(1, h); + depth = new float[this.w * this.h]; + Arrays.fill(depth, Float.POSITIVE_INFINITY); + img = new BufferedImage(this.w, this.h, BufferedImage.TYPE_INT_ARGB); + px = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData(); + } + + BufferedImage image() { + return img; + } + + /** Depth at a pixel (infinity = empty). */ + float depthAt(int x, int y) { + return depth[y * w + x]; + } + + int rgbAt(int x, int y) { + return px[y * w + x]; + } + + /** Fills a triangle (pixel centres inside, either winding); edge = darken pixels within one pixel of an edge. */ + void triangle( + double x0, + double y0, + double z0, + double x1, + double y1, + double z1, + double x2, + double y2, + double z2, + int rgb, + boolean edge) { + double area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (Math.abs(area) < 1e-9) { + return; + } + int minX = Math.max(0, (int) Math.floor(Math.min(x0, Math.min(x1, x2)))); + int maxX = Math.min(w - 1, (int) Math.ceil(Math.max(x0, Math.max(x1, x2)))); + int minY = Math.max(0, (int) Math.floor(Math.min(y0, Math.min(y1, y2)))); + int maxY = Math.min(h - 1, (int) Math.ceil(Math.max(y0, Math.max(y1, y2)))); + double inv = 1 / area; + double l0 = Math.hypot(x2 - x1, y2 - y1), l1 = Math.hypot(x0 - x2, y0 - y2), l2 = Math.hypot(x1 - x0, y1 - y0); + int dark = 0xff000000 + | (int) ((rgb >> 16 & 0xff) * 0.8) << 16 + | (int) ((rgb >> 8 & 0xff) * 0.8) << 8 + | (int) ((rgb & 0xff) * 0.8); + int full = 0xff000000 | rgb; + for (int y = minY; y <= maxY; y++) { + double py = y + 0.5; + for (int x = minX; x <= maxX; x++) { + double pxc = x + 0.5; + // barycentric weights (scaled by the signed area, so the winding does not matter) + double w0 = ((x2 - x1) * (py - y1) - (y2 - y1) * (pxc - x1)) * inv; + double w1 = ((x0 - x2) * (py - y2) - (y0 - y2) * (pxc - x2)) * inv; + double w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) { + continue; + } + float z = (float) (w0 * z0 + w1 * z1 + w2 * z2); + int i = y * w + x; + if (z >= depth[i]) { + continue; + } + depth[i] = z; + boolean onEdge = edge + && (w0 * Math.abs(area) / l0 < 1 + || w1 * Math.abs(area) / l1 < 1 + || w2 * Math.abs(area) / l2 < 1); + px[i] = onEdge ? dark : full; + } + } + } +} 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 bab5df7..9a0c076 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 @@ -16,6 +16,7 @@ import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Tool; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import com.eliasmueller.llamamod.editor.ui.view.Thumbnails; @@ -178,13 +179,14 @@ final class TilePaintTool implements Tool { hoverY = t.y(); rot = Math.floorMod(Math.round(t.rot() / 1024f), 4); syncUi(); - ctx.status("Picked " + (model == null ? "?" : model.id()) + " at height " + BrushTool.fmt(height) + ", " - + rot * 90 + "°"); + ctx.status("Picked " + (model == null ? "?" : NodeNames.modelName(model)) + " at height " + + BrushTool.fmt(height) + ", " + rot * 90 + "°"); } private void syncUi() { if (current != null) { - current.setText(model == null ? "No model – pick one below or Shift+click a tile" : model.label()); + current.setText( + model == null ? "No model – pick one below or Shift+click a tile" : NodeNames.modelName(model)); current.setIcon(model == null ? null : thumb(model, 48)); } if (heightField != null) { @@ -247,7 +249,7 @@ final class TilePaintTool implements Tool { t.setRaw("model", model); t.setRaw("colors", colours(s, model)); ctx.execute(new Stroke("Paint tiles", new AddNode(t), stroke)); - ctx.status("Painted " + model.id() + " in cell " + key); + ctx.status("Painted " + NodeNames.modelName(model) + " in cell " + key); } // ------------------------------------------------------------------ mouse @@ -284,7 +286,7 @@ final class TilePaintTool implements Tool { painted.add(t.id()); ctx.execute(new Stroke("Erase tiles", Edits.delete(ctx.scene(), List.of(t)), stroke)); ctx.status( - "Erased " + t.label() + "Erased " + NodeNames.name(t) + (t.id().contains(":n") ? "" : " (an original tile: strat scripts" @@ -309,7 +311,7 @@ final class TilePaintTool implements Tool { cmds.add(SetProp.of(t, "model", model)); cmds.add(SetProp.of(t, "colors", colours(ctx.scene(), model))); ctx.execute(new Stroke("Swap tile model", new Batch("Swap tile model", cmds), stroke)); - ctx.status(t.label() + " now uses " + model.id()); + ctx.status("Tile " + t.cellX() + ", " + t.cellZ() + " now uses " + NodeNames.modelName(model)); } } default -> {} @@ -344,7 +346,7 @@ final class TilePaintTool implements Tool { hoverTile = tileAt(e); hoverCell = null; if (hoverTile != null) { - ctx.status(hoverTile.label() + ", height " + BrushTool.fmt(hoverTile.y()) + ", " + ctx.status(NodeNames.name(hoverTile) + ", height " + BrushTool.fmt(hoverTile.y()) + ", " + Math.round(hoverTile.rot() * 360.0 / 4096) + "°"); } } @@ -522,8 +524,8 @@ final class TilePaintTool implements Tool { break; } JToggleButton b = new JToggleButton(thumb(m, 48), m == model); - b.setToolTipText( - m.label() + " – " + m.vertexCount() + " vertices, " + uses.getOrDefault(m, 0) + " tile(s)"); + b.setToolTipText(NodeNames.modelName(m) + " – " + m.vertexCount() + " vertices, " + + uses.getOrDefault(m, 0) + " tile(s)"); b.setPreferredSize(new Dimension(56, 56)); b.setFocusable(false); b.addActionListener(e -> { @@ -532,7 +534,7 @@ final class TilePaintTool implements Tool { if (mode != Mode.SWAP) { mode = Mode.PAINT; } - ctx.status("Palette: " + m.label()); + ctx.status("Palette: " + NodeNames.modelName(m)); }); grid.add(b); } 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 index 70c7667..bd8c527 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java @@ -1,5 +1,6 @@ package com.eliasmueller.llamamod.editor.tools.gltf; +import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.ui.ActionSpec; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorExtensions; @@ -8,6 +9,7 @@ import java.awt.BorderLayout; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Locale; import javax.swing.BorderFactory; import javax.swing.BoxLayout; @@ -17,21 +19,23 @@ 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). */ +/** File → Export level as glTF… (level or selection: tiles, brushes, meshes, optionally objects and collision). */ 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)")); + x.action( + ActionSpec.of( + "file.exportGltf", + "File", + 30, + 45, + "Export level as glTF…", + "export", + null, + GltfEditorPlugin::export) + .enabledWhen(c -> c.document() != null) + .tip( + "The level (or the selection) with textures and vertex colours as .glb / .gltf (Blender, viewers)")); } static void export(EditorContext ctx) { @@ -49,9 +53,20 @@ public final class GltfEditorPlugin implements EditorPlugin { 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)); + List sel = List.copyOf(ctx.selection().nodes()); + JCheckBox selection = new JCheckBox("Selection only"); + selection.setEnabled(!sel.isEmpty()); + selection.setSelected(!sel.isEmpty() && ctx.prefs().getBool("gltf.selectionOnly", false)); + selection.setToolTipText(sel.isEmpty() ? "Nothing selected" : "Only the selected tiles, brushes and objects"); + JCheckBox collision = + new JCheckBox("Collision as separate mesh", ctx.prefs().getBool("gltf.collision", false)); + collision.setToolTipText( + "Collision polygons in their own node \"collision\", untextured, coloured by material"); JPanel boxes = new JPanel(); boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS)); + boxes.add(selection); boxes.add(objects); + boxes.add(collision); boxes.add(hidden); JPanel acc = new JPanel(new BorderLayout(0, 6)); acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0)); @@ -73,16 +88,28 @@ public final class GltfEditorPlugin implements EditorPlugin { 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.collision", collision.isSelected()); + if (selection.isEnabled()) { + ctx.prefs().put("gltf.selectionOnly", selection.isSelected()); + } GltfExport.Geometry g; try { g = GltfExport.collect( ctx.scene(), - new GltfExport.Options(objects.isSelected()), + new GltfExport.Options( + objects.isSelected(), collision.isSelected(), selection.isSelected() ? sel : null), n -> hidden.isSelected() && ctx.isHidden(n)); } catch (RuntimeException e) { ctx.error("Export glTF", e); return; } + if (g.triangles() == 0) { + ctx.status( + selection.isSelected() + ? "Nothing to export: the selection has no tiles, brushes or (with Object models) objects" + : "Nothing to export"); + return; + } Path out = f; ctx.runTask( "Exporting " + f.getFileName(), 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 index b88d0d5..9cdf1be 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java @@ -17,10 +17,14 @@ import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Predicate; import org.joml.Matrix3f; import org.joml.Vector3f; @@ -40,8 +44,16 @@ import org.joml.Vector3f; public final class GltfExport { private GltfExport() {} - /** objects: also the models of placed objects (strats) as the viewport shows them. */ - public record Options(boolean objects) {} + /** + * objects: also the models of placed objects (strats) as the viewport shows them; collision: the collision + * polygons as a separate untextured mesh/node "collision" (one material per collision material); only: export + * just these nodes (tiles, brushes, objects; null = the whole level). + */ + public record Options(boolean objects, boolean collision, Collection only) { + public Options(boolean objects) { + this(objects, false, null); + } + } /** One glTF primitive: unique vertices + triangle indices. */ static final class Prim { @@ -49,6 +61,9 @@ public final class GltfExport { final int blend; final boolean doubleSided; final String group; + /** Collision material of a "collision" primitive, else -1. */ + int collisionMat = -1; + final List verts = new ArrayList<>(); final List cols = new ArrayList<>(); final Map index = new HashMap<>(); @@ -144,7 +159,7 @@ public final class GltfExport { Map matOf = new LinkedHashMap<>(); Map> meshPrims = new LinkedHashMap<>(); for (Prim p : prims) { - String mk = p.page + "/" + p.blend + "/" + p.doubleSided; + String mk = p.page + "/" + p.blend + "/" + p.doubleSided + "/" + p.collisionMat; Integer mi = matOf.get(mk); if (mi == null) { mi = materials.size(); @@ -237,6 +252,23 @@ public final class GltfExport { private static Map material(Prim p, Map imageOf, List pages) { Map pbr = new LinkedHashMap<>(); + if (p.collisionMat >= 0) { + int c = collisionColour(p.collisionMat); + pbr.put( + "baseColorFactor", + List.of((c >> 16 & 0xff) / 255.0, (c >> 8 & 0xff) / 255.0, (c & 0xff) / 255.0, 1)); + pbr.put("metallicFactor", 0); + pbr.put("roughnessFactor", 1); + return map( + "name", + "collision_" + p.collisionMat, + "pbrMetallicRoughness", + pbr, + "doubleSided", + true, + "extensions", + map("KHR_materials_unlit", Map.of())); + } pbr.put("baseColorFactor", List.of(1, 1, 1, 1)); if (p.page >= 0) { pbr.put("baseColorTexture", map("index", imageOf.get(p.page))); @@ -260,6 +292,16 @@ public final class GltfExport { return m; } + /** Colour of a collision material: 16 exit green, 30 death red, 0 grey, others spread over the hues. */ + static int collisionColour(int mat) { + return switch (mat) { + case 0 -> 0x9a9a9a; + case 16 -> 0x3cc85a; + case 30 -> 0xe04040; + default -> java.awt.Color.HSBtoRGB((mat * 0.137f) % 1f, 0.6f, 0.9f) & 0xffffff; + }; + } + private static boolean hasCutout(TexturePage p) { for (int c : p.rgba()) { if ((c & 0xff) < 128) { @@ -359,13 +401,20 @@ public final class GltfExport { int[] rgba = new int[3]; Vector3f e1 = new Vector3f(); Vector3f e2 = new Vector3f(); + Set only = null; + if (o.only() != null) { + only = Collections.newSetFromMap(new IdentityHashMap<>()); + only.addAll(o.only()); + } + Set onlyF = only; + Predicate leaveOut = x -> onlyF != null && (x == null || !onlyF.contains(x)) || x != null && skip.test(x); 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)) { + if (owner instanceof Tile t && t.excluded() || leaveOut.test(owner)) { continue; } RenderMesh m = ri.mesh; @@ -430,6 +479,9 @@ public final class GltfExport { } } } + if (o.collision()) { + collision(s, sr, rs, leaveOut, prims); + } List l = new ArrayList<>(); for (Prim pr : prims.values()) { if (!pr.tris.isEmpty()) { @@ -440,6 +492,81 @@ public final class GltfExport { return new Geometry(name == null || name.isBlank() ? "level" : name, l, List.copyOf(rs.pages)); } + /** + * Collision polygons of scene tiles and of the tiles compiled from brushes (fan triangles, game space), + * one primitive per material in the group "collision". Tiles with "No collision" have none. + */ + private static void collision( + Scene s, SceneRenderer sr, RenderScene rs, Predicate leaveOut, Map prims) { + List tiles = new ArrayList<>(); + List owners = new ArrayList<>(); + for (Tile t : s.tiles()) { + if (!t.excluded() && !leaveOut.test(t)) { + tiles.add(t); + owners.add(t); + } + } + // compiled brush tiles: the renderer adds one instance per tile with a model, in output order + List compiled = new ArrayList<>(); + if (sr.brushOutput() != null) { + for (Tile t : sr.brushOutput().tiles()) { + if (t.model() != null) { + compiled.add(t); + } + } + } + List brushOwners = new ArrayList<>(); + for (RenderInstance ri : rs.instances) { + Node n = sr.nodeOf(ri.id); + if (ri.kind == RenderInstance.Kind.TILE && !(n instanceof Tile)) { + brushOwners.add(n); + } + } + for (int i = 0; i < compiled.size(); i++) { + Node owner = brushOwners.size() == compiled.size() ? brushOwners.get(i) : null; + if (!leaveOut.test(owner)) { + tiles.add(compiled.get(i)); + owners.add(owner); + } + } + Vector3f e1 = new Vector3f(); + Vector3f e2 = new Vector3f(); + for (Tile t : tiles) { + for (var poly : t.worldCollisionPolygons()) { + double[][] c = poly.corners(); + int mat = poly.material(); + Prim pr = prims.computeIfAbsent("collision/" + mat, k -> { + Prim np = new Prim("collision", -1, RenderMesh.BLEND_OPAQUE, true); + np.collisionMat = mat; + return np; + }); + for (int k = 1; k + 1 < c.length; k++) { + // export → game space: z negated, winding reversed + double[][] tri = {c[0], c[k + 1], c[k]}; + e1.set((float) (tri[1][0] - tri[0][0]), (float) (tri[1][1] - tri[0][1]), (float) + (tri[0][2] - tri[1][2])); + e2.set((float) (tri[2][0] - tri[0][0]), (float) (tri[2][1] - tri[0][1]), (float) + (tri[0][2] - tri[2][2])); + e1.cross(e2); + if (!(e1.lengthSquared() > 1e-14f)) { + continue; + } + e1.normalize(); + double[] pn = poly.normal(); + if (pn != null && e1.x * pn[0] + e1.y * pn[1] - e1.z * pn[2] < 0) { + // follow the polygon's own normal (floors face up) + tri = new double[][] {tri[0], tri[2], tri[1]}; + e1.negate(); + } + for (double[] q : tri) { + float[] v = {(float) q[0], (float) q[1], (float) -q[2], e1.x, e1.y, e1.z, 0, 0}; + pr.tris.add(pr.vertex(v, 0xffffffff)); + } + } + } + } + } + /** 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); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/PaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/PaintTool.java index 9689073..7435c65 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/PaintTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/PaintTool.java @@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.Icons; import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Tool; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import com.eliasmueller.llamamod.format.Geometry; @@ -160,7 +161,7 @@ public final class PaintTool implements Tool { ctx.status(String.format( Locale.ROOT, "%s · face %d · texture %s · flags 0x%x%s", - h.model().label(), + NodeNames.modelName(h.model()), h.face(), te == null ? "#" + t : te.label(), h.model().faceFlags(h.face()), @@ -316,7 +317,7 @@ public final class PaintTool implements Tool { String param = brushParam(b, n); lastParam = param; if (param == null) { - ctx.status(b.label() + " has no texture parameter"); + ctx.status(NodeNames.name(b) + " has no texture parameter"); return; } if (eyedropper || e.shift()) { @@ -329,7 +330,7 @@ public final class PaintTool implements Tool { // name } } - ctx.status(b.label() + " · " + param + " = " + v); + ctx.status(NodeNames.name(b) + " · " + paramName(b, param) + " = " + v); setEyedropper(false); return; } @@ -339,7 +340,15 @@ public final class PaintTool implements Tool { return; } ctx.execute(SetProp.of(b, param, "#" + t)); - ctx.status(b.label() + ": " + param + " = #" + t); + ctx.status(NodeNames.name(b) + ": " + paramName(b, param) + " = #" + t); + } + + private static String paramName(Brush b, String param) { + try { + return b.brushKind().param(param).label(); + } catch (IllegalArgumentException e) { + return param; + } } private void rotateBrush() { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/AxisLayout.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/AxisLayout.java new file mode 100644 index 0000000..7ea94b6 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/AxisLayout.java @@ -0,0 +1,92 @@ +package com.eliasmueller.llamamod.editor.ui; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.LayoutManager; +import javax.swing.SwingUtilities; + +/** + * X/Y/Z cells in one row while each gets at least the minimum cell width, else stacked (one per row), so + * the fields stay readable in a very narrow dock. + */ +public final class AxisLayout implements LayoutManager { + /** Default minimum cell width (axis letter + number field). */ + public static final int CELL_MIN = 66; + + static final int GAP = 4; + private final int cellMin; + private int lastRows = 1; + + /** @param cellMin narrowest width of one cell before the cells are stacked */ + public AxisLayout(int cellMin) { + this.cellMin = cellMin; + } + + boolean stacked(int width, int n) { + return width > 0 && width < n * cellMin + (n - 1) * GAP; + } + + @Override + public void addLayoutComponent(String name, Component comp) {} + + @Override + public void removeLayoutComponent(Component comp) {} + + private Dimension size(Container target, boolean preferred) { + Component[] cs = target.getComponents(); + int n = cs.length; + int ch = 0; + for (Component c : cs) { + ch = Math.max(ch, c.getPreferredSize().height); + } + Insets in = target.getInsets(); + int rows = stacked(target.getWidth() - in.left - in.right, n) ? n : 1; + int w = preferred ? n * cellMin + (n - 1) * GAP : cellMin; + return new Dimension(w + in.left + in.right, rows * ch + (rows - 1) * GAP + in.top + in.bottom); + } + + @Override + public Dimension preferredLayoutSize(Container target) { + return size(target, true); + } + + @Override + public Dimension minimumLayoutSize(Container target) { + return size(target, false); + } + + @Override + public void layoutContainer(Container target) { + Component[] cs = target.getComponents(); + int n = cs.length; + if (n == 0) { + return; + } + Insets in = target.getInsets(); + int w = target.getWidth() - in.left - in.right; + int h = target.getHeight() - in.top - in.bottom; + boolean st = stacked(w, n); + if (st) { + int ch = (h - (n - 1) * GAP) / n; + for (int i = 0; i < n; i++) { + cs[i].setBounds(in.left, in.top + i * (ch + GAP), w, ch); + } + } else { + int cw = (w - (n - 1) * GAP) / n; + for (int i = 0; i < n; i++) { + cs[i].setBounds(in.left + i * (cw + GAP), in.top, i == n - 1 ? w - i * (cw + GAP) : cw, h); + } + } + int rows = st ? n : 1; + if (rows != lastRows) { + lastRows = rows; + // the parent asked for our height before we knew our width: let it ask again + SwingUtilities.invokeLater(() -> { + Container p = target.getParent(); + (p != null ? p : target).revalidate(); + }); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java index 9c7baa0..2918d99 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java @@ -394,7 +394,7 @@ final class BuiltinEditorPlugin implements EditorPlugin { || bb[3] > s.settings().width() || bb[5] > s.settings().height())) { out.add(Problem.warning( - b.id() + " (" + b.brushKind().label() + ") reaches outside the " + NodeNames.name(b) + " reaches outside the " + s.settings().width() + "×" + s.settings().height() + " grid", b)); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java index 35e630b..7eb156c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java @@ -13,6 +13,7 @@ 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.Values; +import com.eliasmueller.llamamod.editor.ui.AxisLayout; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.HelpTopics; @@ -95,6 +96,16 @@ public final class InspectorPanel extends JPanel { JScrollPane sp = new JScrollPane(body); sp.setBorder(BorderFactory.createEmptyBorder()); sp.getVerticalScrollBar().setUnitIncrement(16); + sp.getViewport().addComponentListener(new java.awt.event.ComponentAdapter() { + @Override + public void componentResized(java.awt.event.ComponentEvent e) { + int w = sp.getViewport().getWidth(); + if (w > 0 && (w < NARROW) != narrow) { + narrow = w < NARROW; + rebuild(); + } + } + }); add(sp, BorderLayout.CENTER); ctx.selection().addListener(c -> rebuild()); ctx.addDocumentListener(d -> rebuild()); @@ -364,7 +375,7 @@ public final class InspectorPanel extends JPanel { c.anchor = GridBagConstraints.WEST; c.insets = new Insets(2, 12, 2, 8); JLabel l = label != null ? label : new JLabel(text); - fitLabel(l, LABEL_W); + fitLabel(l, labelWidth()); l.setForeground(UIManager.getColor("Label.foreground")); if (tip != null && !tip.isEmpty()) { l.setToolTipText(tip); @@ -379,6 +390,15 @@ public final class InspectorPanel extends JPanel { } private static final int LABEL_W = 118; + private static final int LABEL_W_NARROW = 80; + /** Below this inspector width the label column is narrower (labels wrap) so the editors stay usable. */ + static final int NARROW = 300; + + private boolean narrow; + + private int labelWidth() { + return narrow ? LABEL_W_NARROW : LABEL_W; + } /** * Gives a property label the fixed column width; a longer text wraps onto more lines instead of being cut. @@ -682,7 +702,7 @@ public final class InspectorPanel extends JPanel { } private JComponent vector(List ps) { - JPanel p = new JPanel(new GridLayout(1, 3, 4, 0)); + JPanel p = new JPanel(new AxisLayout(AxisLayout.CELL_MIN)); p.setOpaque(false); Color[] cols = {new Color(0xD9, 0x3F, 0x3F), new Color(0x3F, 0xA3, 0x4D), new Color(0x3C, 0x82, 0xE6)}; String[] ax = {"X", "Y", "Z"}; @@ -817,7 +837,7 @@ public final class InspectorPanel extends JPanel { private JComponent ref(Prop p, boolean ro) { JPanel panel = new JPanel(new BorderLayout(4, 0)); panel.setOpaque(false); - JButton go = new JButton(); + RefButton go = new RefButton(); go.setHorizontalAlignment(SwingConstants.LEFT); go.setToolTipText("Select the referenced item"); JButton pick = new JButton("…"); @@ -826,12 +846,11 @@ public final class InspectorPanel extends JPanel { Binding b = () -> { Object v = value(p.name()); if (v instanceof Node n) { - go.setText(NodeNames.name(n)); - go.setToolTipText("Select the referenced item (" + n.id() + ")"); - go.setIcon(NodeIcons.icon(n, 14)); + go.setTexts(NodeIcons.icon(n, 14), refNames(n)); + go.setToolTipText("Select " + go.texts[0] + " (" + n.id() + ")"); } else { - go.setText(v == MIXED ? "—" : "(none)"); - go.setIcon(null); + go.setTexts(null, v == MIXED ? "—" : "(none)"); + go.setToolTipText("Select the referenced item"); } go.setEnabled(v instanceof Node); }; @@ -1074,6 +1093,91 @@ public final class InspectorPanel extends JPanel { return cb; } + /** Full name first, then shorter forms for a narrow dock ("Waypoint 5 (chain 3)", "Waypoint 5", "5"). */ + static String[] refNames(Node n) { + if (n instanceof PathNode p && p.scene() != null) { + NodeNames nn = NodeNames.of(p.scene()); + String shortName = nn.waypointName(p, false); + return new String[] {nn.nameOf(p), shortName, shortName.replace("Waypoint ", "#")}; + } + String full = NodeNames.name(n); + int i = full.lastIndexOf(" ("); + return i > 0 && full.endsWith(")") ? new String[] {full, full.substring(0, i)} : new String[] {full}; + } + + /** + * Button that shows the longest of its texts that fits its width (the last one is cut with "…" by Swing). + * Its preferred width is that of the full text and does not change with the shown text. + */ + static final class RefButton extends JButton { + String[] texts = {""}; + private javax.swing.Icon nodeIcon; + + /** The icon is dropped when not even the shortest text fits next to it. */ + void setTexts(javax.swing.Icon icon, String... t) { + nodeIcon = icon; + texts = t; + setIcon(icon); + setText(t[0]); + fit(); + } + + private int extra(boolean icon) { + Insets in = getInsets(); + return in.left + in.right + (icon && nodeIcon != null ? nodeIcon.getIconWidth() + getIconTextGap() : 0) + 2; + } + + private int textWidth(String t) { + return getFontMetrics(getFont()).stringWidth(t); + } + + @Override + public Dimension getPreferredSize() { + Dimension d = super.getPreferredSize(); + d.width = textWidth(texts[0]) + extra(true); + return d; + } + + @Override + public Dimension getMinimumSize() { + Dimension d = super.getMinimumSize(); + d.width = Math.min(getPreferredSize().width, extra(false) + 20); + return d; + } + + @Override + public void setBounds(int x, int y, int w, int h) { + super.setBounds(x, y, w, h); + fit(); + } + + private void fit() { + int w = getWidth(); + if (w <= 0) { + return; + } + String pick = null; + for (String t : texts) { + if (textWidth(t) + extra(true) <= w) { + pick = t; + break; + } + } + boolean icon = pick != null || textWidth(texts[texts.length - 1]) + extra(true) <= w + 1; + if (pick == null) { + pick = texts[texts.length - 1]; + icon = false; + } + if (!pick.equals(getText())) { + setText(pick); + } + javax.swing.Icon want = icon ? nodeIcon : null; + if (getIcon() != want) { + setIcon(want); + } + } + } + /** Body that tracks the viewport width (no horizontal scrolling). */ private static final class ScrollPanel extends JPanel implements Scrollable { @Override diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java index 401dc45..cb25ef1 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java @@ -292,11 +292,10 @@ public final class OutlinerPanel extends JPanel { if (q.isEmpty()) { return true; } - String l = n.label().toLowerCase(Locale.ROOT); - if (l.contains(q) || n.id().contains(q) || nameMatches(names.nameOf(n), q)) { + if (nameMatches(n.label(), q) || nameMatches(n.id(), q) || nameMatches(names.nameOf(n), q)) { return true; } - if (n instanceof Tile t && ("cell " + t.cellX() + ", " + t.cellZ()).contains(q)) { + if (n instanceof Tile t && nameMatches("cell " + t.cellX() + ", " + t.cellZ(), q)) { return true; } return n instanceof Strat st && StratCatalog.category(st.type()).contains(q); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/tools/EditOps.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/tools/EditOps.java index f8c4a16..650c83f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/tools/EditOps.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/tools/EditOps.java @@ -16,6 +16,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorWindow; import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.NodeTransformer; import java.util.ArrayList; import java.util.Arrays; @@ -245,9 +246,9 @@ public final class EditOps { int[] col = new int[nv]; Arrays.fill(col, Tile.GREY); t.setRaw("colors", col); - ctx.execute(new Batch("Place tile " + m.id(), List.of(new AddNode(t)))); + ctx.execute(new Batch("Place tile", List.of(new AddNode(t)))); ctx.selection().set(t, ctx); - ctx.status("Placed " + m.label() + " in cell " + (int) x + ", " + (int) -z); + ctx.status("Placed " + NodeNames.modelName(m) + " in cell " + (int) x + ", " + (int) -z); } /** Places an object of a catalog type at the drop point. */ diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java index b49cdfc..36c76c6 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java @@ -45,6 +45,11 @@ class GeometryChecksTest { Edits.placeStrat(s, "LevelDoor", 0, 12.5, 0, 12.5, 0.0).apply(s); List p = check(s); assertTrue(p.stream().anyMatch(m -> m.startsWith("Gap of 0.08")), p.toString()); + String brushA = + com.eliasmueller.llamamod.editor.ui.NodeNames.name(s.brushes().get(0)); + assertTrue( + p.stream().anyMatch(m -> m.startsWith("Gap of") && m.contains(brushA) && !m.contains("brush:")), + p.toString()); assertTrue(p.stream().anyMatch(m -> m.startsWith("No floor collision under the player start")), p.toString()); assertTrue(p.stream().anyMatch(m -> m.startsWith("No exit floor")), p.toString()); // close the gap, paint the exit floor, move the start onto the floor diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRasterTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRasterTest.java new file mode 100644 index 0000000..02fb6ef --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewRasterTest.java @@ -0,0 +1,50 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The mesh import preview's z-buffer: per-pixel depth instead of sorting triangles by their centroid. */ +class PreviewRasterTest { + private static final int A = 0xff0000, B = 0x0000ff; + + /** A long sloped triangle (centroid depth 33) in front of a small flat one (depth 30) near its near end. */ + private static PreviewRaster draw(boolean aFirst) { + PreviewRaster r = new PreviewRaster(120, 20); + Runnable a = () -> r.triangle(0, 0, 0, 100, 0, 0, 0, 10, 100, A, false); + Runnable b = () -> r.triangle(0, 0, 30, 20, 0, 30, 0, 10, 30, B, false); + if (aFirst) { + a.run(); + b.run(); + } else { + b.run(); + a.run(); + } + return r; + } + + @Test + void nearerPixelWinsInAnyOrder() { + for (boolean aFirst : new boolean[] {true, false}) { + PreviewRaster r = draw(aFirst); + // near the bottom edge the sloped triangle is nearer (depth ~10) although its centroid is farther + assertEquals(0xff000000 | A, r.rgbAt(5, 1), "order " + aFirst); + // high up it is far behind (depth ~80): the flat triangle shows + assertEquals(0xff000000 | B, r.rgbAt(1, 8), "order " + aFirst); + assertEquals(30f, r.depthAt(1, 8), 1e-3); + // outside both: empty + assertEquals(0, r.rgbAt(110, 15)); + assertTrue(Float.isInfinite(r.depthAt(110, 15))); + } + } + + @Test + void windingDoesNotMatterAndEdgesDarken() { + PreviewRaster r = new PreviewRaster(40, 40); + r.triangle(0, 0, 1, 0, 30, 1, 30, 0, 1, 0x808080, true); // clockwise on screen + assertEquals(0xff808080, r.rgbAt(8, 8)); + int edge = r.rgbAt(10, 0); + assertTrue((edge & 0xff) < 0x80, Integer.toHexString(edge)); + } +} 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 index 40a27ed..8fb6769 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java @@ -288,4 +288,133 @@ class GltfExportTest { GltfExport.collect(s, new GltfExport.Options(false), n -> false).triangles(); assertTrue(less < all && less >= all - t.model().faceCount(), all + " → " + less); } + + /** Triangles of the named mesh, and the POSITION bounds of its primitives (lo xyz, hi xyz). */ + static int meshTriangles(Map j, String name, double[] bounds) { + int n = 0; + java.util.Arrays.fill(bounds, 0, 3, 1e9); + java.util.Arrays.fill(bounds, 3, 6, -1e9); + for (Object mo : list(j, "meshes")) { + if (!name.equals(((Map) mo).get("name"))) { + continue; + } + for (Object po : list((Map) mo, "primitives")) { + Map prim = (Map) po; + n += num(((Map) list(j, "accessors").get(num(prim.get("indices")))).get("count")) / 3; + Map pos = + (Map) list(j, "accessors").get(num(((Map) prim.get("attributes")).get("POSITION"))); + for (int k = 0; k < 3; k++) { + bounds[k] = Math.min(bounds[k], ((Number) ((List) pos.get("min")).get(k)).doubleValue()); + bounds[3 + k] = Math.max(bounds[3 + k], ((Number) ((List) pos.get("max")).get(k)).doubleValue()); + } + } + } + return n; + } + + @Test + void selectionOnlyAndCollisionMesh() throws Exception { + Scene s = Project.create(cd(), "edit", "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); + var box = s.brushes().getLast(); + // selection only: just the box, game space x 10..12, y 0..1, z 10..13 + GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, true, List.of(box)), n -> false); + Parsed p = parseGlb(g.glb()); + assertEquals(g.triangles(), validate(p)); + double[] vb = new double[6]; + double[] cb = new double[6]; + int visual = meshTriangles(p.json(), "level", vb); + int coll = meshTriangles(p.json(), "collision", cb); + assertTrue(visual >= 10 && coll >= 10, visual + " / " + coll); + assertEquals(g.triangles(), visual + coll); + double[] want = {10, 0, 10, 12, 1, 13}; + for (int k = 0; k < 6; k++) { + assertEquals(want[k], vb[k], 1e-3, "visual bounds " + k); + assertEquals(want[k], cb[k], 1e-3, "collision bounds " + k); + } + // separate node, untextured materials named by collision material + List nodes = list(p.json(), "nodes"); + assertTrue(nodes.stream().anyMatch(n -> "collision".equals(((Map) n).get("name"))), nodes.toString()); + for (Object mo : list(p.json(), "meshes")) { + if ("collision".equals(((Map) mo).get("name"))) { + for (Object po : list((Map) mo, "primitives")) { + Map prim = (Map) po; + assertTrue(!((Map) prim.get("attributes")).containsKey("TEXCOORD_0")); + Map m = (Map) list(p.json(), "materials").get(num(prim.get("material"))); + assertTrue(((String) m.get("name")).startsWith("collision_"), m.toString()); + assertTrue(!((Map) m.get("pbrMetallicRoughness")).containsKey("baseColorTexture")); + } + } + } + // round trip: the importer reads both nodes, the box collision is a closed box facing outwards + MeshData md = MeshImport.parse("c.glb", g.glb(), f -> null, "y", 1); + assertEquals(g.triangles(), md.triangleCount()); + GltfExport.Geometry collOnly = + GltfExport.collect(s, new GltfExport.Options(false, true, List.of(box)), n -> false); + assertEquals(g.triangles(), collOnly.triangles(), "deterministic"); + + // a single original tile: its drawn triangles and exactly its collision polygons + Tile t = s.tiles().stream() + .filter(x -> x.model() != null + && x.model().faceCount() > 4 + && x.worldCollisionPolygons().size() > 1) + .findFirst() + .orElseThrow(); + GltfExport.Geometry one = GltfExport.collect(s, new GltfExport.Options(false, true, List.of(t)), n -> false); + Parsed op = parseGlb(one.glb()); + assertEquals(one.triangles(), validate(op)); + int fan = t.worldCollisionPolygons().stream() + .mapToInt(q -> q.corners().length - 2) + .sum(); + int oc = meshTriangles(op.json(), "collision", cb); + assertTrue(oc > 0 && oc <= fan, oc + " of " + fan); + int ov = meshTriangles(op.json(), "level", vb); + assertTrue( + ov > 0 && ov <= t.model().faceCount() * 2, + ov + " for " + t.model().faceCount() + " faces"); + assertTrue(vb[0] >= t.x() - 3 && vb[3] <= t.x() + 3, "tile x " + t.x() + ": " + vb[0] + ".." + vb[3]); + assertTrue(MeshImport.parse("t.glb", one.glb(), f -> null, "y", 1).triangleCount() == one.triangles()); + + // whole level: collision mesh covers the original tiles too; without the option no collision node + GltfExport.Geometry all = GltfExport.collect(s, new GltfExport.Options(false, true, null), n -> false); + Parsed ap = parseGlb(all.glb()); + assertEquals(all.triangles(), validate(ap)); + assertTrue(meshTriangles(ap.json(), "collision", cb) > 1000); + Parsed plain = parseGlb( + GltfExport.collect(s, new GltfExport.Options(false), n -> false).glb()); + assertTrue( + list(plain.json(), "nodes").stream().noneMatch(n -> "collision".equals(((Map) n).get("name")))); + // nothing selected that has geometry → empty + assertEquals( + 0, + GltfExport.collect( + s, + new GltfExport.Options( + false, true, List.of(s.lights().getFirst())), + n -> false) + .triangles()); + } + + @Test + void collisionFloorsFaceUp() throws Exception { + Scene s = Project.create(cd(), "new", "t1l1m001").scene(); + Edits.addBrush(s, BrushKind.BOX, Map.of("x0", 4.0, "z0", 4.0, "x1", 6.0, "z1", 6.0, "y0", 0.0, "y1", 1.0)) + .apply(s); + GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, true, null), n -> false); + int up = 0; + for (GltfExport.Prim pr : g.prims) { + if (pr.collisionMat < 0) { + continue; + } + for (float[] v : pr.verts) { + if (Math.abs(v[1] - 1) < 1e-4 && v[4] > 0.99f) { + up++; + } + } + } + assertTrue(up >= 3, "top of the box faces up: " + up); + MeshData md = MeshImport.parse("f.glb", g.glb(), f -> null, "y", 1); + com.eliasmueller.llamamod.editor.tools.geometry.ObjExportRoundTripTest.assertOutward(md, md.bounds()); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java index ad65fd0..560fb23 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java @@ -81,6 +81,17 @@ class OutlinerAndErrorsTest { assertTrue(!wps.isEmpty() && wps.stream().allMatch(r -> r.equals("Waypoint 2")), wps.toString()); String obj = names.objectName(s.strats().getFirst()); assertTrue(filtered(filter, tree, obj).contains(obj), obj); + // whole numbers also for tiles: "tile 3" must not find tiles at x = 30..39 through the old label + int d = java.util.stream.IntStream.range(1, 10) + .filter(k -> s.tiles().stream().anyMatch(t -> t.cellX() / 10 == k)) + .findFirst() + .orElseThrow(); + List td = filtered(filter, tree, "tile " + d); + boolean exact = s.tiles().stream().anyMatch(t -> t.cellX() == d); + assertEquals(exact, td.stream().anyMatch(r -> r.startsWith("Cell " + d + ",")), td.toString()); + assertTrue( + td.stream().filter(r -> r.startsWith("Cell ")).allMatch(r -> r.startsWith("Cell " + d + ",")), + td.toString()); } private static List filtered(javax.swing.JTextField f, JTree t, String q) throws Exception { diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorNarrowTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorNarrowTest.java new file mode 100644 index 0000000..c75b2b6 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorNarrowTest.java @@ -0,0 +1,58 @@ +package com.eliasmueller.llamamod.editor.ui.panels; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.ui.AxisLayout; +import com.eliasmueller.llamamod.editor.ui.Icons; +import java.awt.Dimension; +import javax.swing.JPanel; +import javax.swing.JTextField; +import org.junit.jupiter.api.Test; + +/** Narrow inspector: reference buttons shorten their text, X/Y/Z fields stack instead of shrinking. */ +class InspectorNarrowTest { + @Test + void refButtonPicksTheLongestTextThatFits() { + InspectorPanel.RefButton b = new InspectorPanel.RefButton(); + b.setTexts(Icons.get("waypoint", 14), "Waypoint 5 (chain 3)", "Waypoint 5", "#5"); + int full = b.getPreferredSize().width; + b.setBounds(0, 0, full, 24); + assertEquals("Waypoint 5 (chain 3)", b.getText()); + assertNotNull(b.getIcon()); + b.setBounds(0, 0, full - 20, 24); + assertEquals("Waypoint 5", b.getText()); + int shortW = b.getFontMetrics(b.getFont()).stringWidth("#5"); + b.setBounds(0, 0, shortW + 40, 24); + assertEquals("#5", b.getText()); + b.setBounds(0, 0, shortW + 12, 24); + assertEquals("#5", b.getText()); + assertNull(b.getIcon(), "icon dropped when only the text fits"); + assertEquals(full, b.getPreferredSize().width, "preferred width independent of the shown text"); + b.setBounds(0, 0, full, 24); + assertEquals("Waypoint 5 (chain 3)", b.getText()); + assertNotNull(b.getIcon()); + } + + @Test + void axisCellsStackWhenNarrow() { + JPanel p = new JPanel(new AxisLayout(AxisLayout.CELL_MIN)); + JTextField[] f = {new JTextField("29.406"), new JTextField("7.25"), new JTextField("64.625")}; + for (JTextField t : f) { + p.add(t); + } + int h = f[0].getPreferredSize().height; + p.setSize(300, 100); + p.doLayout(); + assertEquals(f[0].getY(), f[2].getY(), "one row when wide"); + assertTrue(f[0].getWidth() >= AxisLayout.CELL_MIN); + assertEquals(h, p.getPreferredSize().height); + p.setSize(120, 3 * h + 8); + p.doLayout(); + assertTrue(f[1].getY() > f[0].getY() && f[2].getY() > f[1].getY(), "stacked when narrow"); + assertEquals(120, f[0].getWidth(), "each field gets the full width"); + assertEquals(new Dimension(AxisLayout.CELL_MIN, 3 * h + 8), p.getMinimumSize()); + } +}