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:
1 parent
d12c642b37
commit
01208aa36f
30 files changed
+840
-100
No files matched your search
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
> **Note:** The exported textures are game data – fine for your own use, please don't share them.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-25
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user