editor: readable names in tool status and problem texts, waypoint reference buttons shorten to fit, x/y/z fields stack in a narrow inspector, whole-number outliner filter for tiles, z-buffered mesh import preview, gltf export of the selection only and of collision as a separate mesh

This commit is contained in:
MineTec committed 2026-11-07 13:39:54 +01:00
1 parent d12c642b37
commit 01208aa36f
30 files changed
+840 -100

No files matched your search

+7 -4
View File
@@ -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.
+1 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 104 KiB

+19 -5
View File
@@ -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_<mat>`, 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).
+8
View File
@@ -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 `<body style='width:…'>`); `onTextChange`, `onMenuOpen` |
| `ErrorDialog` | `ctx.error(title, t)` uses it: plain sentence (`ErrorDialog.plain(t)`, no exception class names) + collapsible **Details** (stack trace) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -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<Problem> 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));
}
}
}
@@ -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);
}
}
};
@@ -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<String, Object> 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]));
@@ -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)" : ""));
}
@@ -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) {
@@ -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<Problem> 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<Problem> out) {
NodeNames names = NodeNames.of(s);
List<Brush> l = new ArrayList<>();
List<double[]> 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)));
}
@@ -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);
}
}
@@ -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));
@@ -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;
}
}
}
}
@@ -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);
}
@@ -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<Node> 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(),
@@ -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<? extends Node> 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<float[]> verts = new ArrayList<>();
final List<Integer> cols = new ArrayList<>();
final Map<VKey, Integer> index = new HashMap<>();
@@ -144,7 +159,7 @@ public final class GltfExport {
Map<String, Integer> matOf = new LinkedHashMap<>();
Map<String, List<Object>> meshPrims = new LinkedHashMap<>();
for (Prim p : prims) {
String mk = p.page + "/" + p.blend + "/" + p.doubleSided;
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<String, Object> material(Prim p, Map<Integer, Integer> imageOf, List<TexturePage> pages) {
Map<String, Object> 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<Node> only = null;
if (o.only() != null) {
only = Collections.newSetFromMap(new IdentityHashMap<>());
only.addAll(o.only());
}
Set<Node> onlyF = only;
Predicate<Node> 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<Prim> 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<Node> leaveOut, Map<String, Prim> prims) {
List<Tile> tiles = new ArrayList<>();
List<Node> 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<Tile> compiled = new ArrayList<>();
if (sr.brushOutput() != null) {
for (Tile t : sr.brushOutput().tiles()) {
if (t.model() != null) {
compiled.add(t);
}
}
}
List<Node> 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);
@@ -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() {
@@ -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();
});
}
}
}
@@ -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));
}
@@ -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<Prop> 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("<html>Select <b>" + go.texts[0] + "</b> (" + n.id() + ")</html>");
} 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
@@ -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);
@@ -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. */
@@ -45,6 +45,11 @@ class GeometryChecksTest {
Edits.placeStrat(s, "LevelDoor", 0, 12.5, 0, 12.5, 0.0).apply(s);
List<String> 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
@@ -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));
}
}
@@ -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());
}
}
@@ -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<String> 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<String> filtered(javax.swing.JTextField f, JTree t, String q) throws Exception {
@@ -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());
}
}