mesh import fit ignores far-off stray pieces (with hint), project load warnings with readable names, edit projects of imported levelbuild wads load again (header runs applied to original values), -0.0 kept in saved arrays; start cameras checked in game on the test world and a village 2 base

This commit is contained in:
MineTec committed 2026-11-07 15:01:46 +01:00
1 parent ec3c619120
commit d56f3c604f
14 files changed
+215 -20

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ cells.
|---|---|
| **Up axis** | **Y up (glTF, most OBJ)** or **Z up (some CAD exports)** |
| **Unit scale (file units → cells)** | how many cells one unit of the file is |
| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side becomes this many cells (default 4) |
| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side of the main part becomes this many cells (default 4); far-off stray pieces are left out, and a hint below says so |
| **Turn** | 0°, 90°, 180° or 270° about the vertical axis |
| **Mirror (left ↔ right)** | mirrors the model |
+6 -2
View File
@@ -55,10 +55,14 @@ deterministic; brushes use the stored `seed`).
Node kinds, ids and properties: `notes/editor-api.md` (every persistent, settable `Prop`).
Values by `PropType`: numbers (INT/FIXED/ANGLE/FLAGS/COLOR as integers, DOUBLE), booleans,
strings, REF = node id (or `null`). Arrays: up to 64 elements as JSON lists, longer ones as
`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`. A *changed*
`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`; a short double
array containing -0.0 is written as `f64` too (JSON numbers drop the sign; since 2026-11-07). A *changed*
array of unchanged length is stored as runs of the changed elements only:
`{"runs": [[start, values], …]}` (values encoded as above) – e.g. editing a few vertices or
pixels of an original page stores just those values.
pixels of an original page stores just those values. Runs apply to the reference value read before any
property of the node is set (setting a model's vertices clears its header; until 2026-11-07 header runs
after a vertex change failed with "project file is damaged: NullPointerException", e.g. on
`llamamod --editor-import` of a levelbuild WAD).
Loading: reference scene → removals → create all added nodes (all kinds, so references across
kinds resolve) → set values of added/changed nodes and settings → apply `order`. Problems
+4 -1
View File
@@ -91,7 +91,10 @@ Invisible collision volumes (Invisible wall/block, colliders) are drawn transluc
### Mesh import (File → Import mesh)
File chooser, then a dialog: rotatable preview (mouse wheel zooms; framed on the main mass –
`Preview.focusBounds`: per axis the vertices within 2.5 IQR of the middle half (Tukey fences), the full
bounds unless they are over 1.5× larger; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (shown as in game; flat shading with a small z-buffer at
bounds unless they are over 1.5× larger; since 2026-11-07 also minus the smaller vertex group beyond a gap
wider than half the extent and twice the kept side, holding under half the other side's vertices – a stray triangle on a
low-poly box is 3 of 11 vertices and passes the fences; **Fit** uses the same bounds, and a hint under
"Largest side" names the main part's size when pieces are ignored, `geo_import_fit_stray.jpg`; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+3 -1
View File
@@ -138,7 +138,9 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
rest on the llama): on village 2 bases (node 2 has unk2 ≠ 0) that put the camera into the llama's
head. Waypoint lists take an optional 5th value unk2: `[(x,z,y,deg,unk2)]`.
In game (test world, apptest 2026-11-07): the start camera is behind the llama at the default
distance (`notes/img/editor/testworld_start_camera.jpg`).
distance (`notes/img/editor/testworld_start_camera.jpg`). Village 2 base (levelbuild floor + player on
t1l1m002, `--editor-import` as an edit project, boot-to-level 1/2): camera behind the llama, not in its
head (`notes/img/editor/village2_levelbuild_start_camera.jpg`).
* **LevelDoor** (exit) – solved, see section "Exit (LevelDoor)" below.
* Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB
(originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models,
+3 -1
View File
@@ -24,7 +24,9 @@ The editor's play-test (`PlaytestRunner.install`) should write the bound form
**Start camera along** (`StartOverride.apply`): the llama's chain moves and turns with the start when it
is a levelbuild/editor start camera – one node within 2.5 cells (`PlayerStart`, levelbuild since
2026-11-07) or the older "node 8 behind + nodes on the llama" chain; original chains stay. Test:
`BootToLevelModTest.singleCameraNodeMovesWithTheStart`.
`BootToLevelModTest.singleCameraNodeMovesWithTheStart`. In game (apptest 2026-11-07, test world,
`start=21, 0.1, 15, 90`, i.e. moved and turned from the plaza start 18/14.6 yaw 0): camera behind the llama,
facing the stairs (`img/editor/bootstart_testworld_camera.jpg`).
Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load
slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3,
@@ -20,6 +20,7 @@ import java.util.Map;
*/
final class NodeCodec {
static final int INLINE = 64;
private static final long NEG_ZERO = Double.doubleToRawLongBits(-0.0);
private NodeCodec() {}
@@ -54,7 +55,9 @@ final class NodeCodec {
}
private static Object doubles(double[] a) {
if (a.length <= INLINE) {
// JSON numbers lose the sign of -0.0 (imported models have it; it changes the built bytes)
if (a.length <= INLINE
&& java.util.Arrays.stream(a).noneMatch(x -> Double.doubleToRawLongBits(x) == NEG_ZERO)) {
List<Object> l = new ArrayList<>(a.length);
for (double x : a) {
l.add(x);
@@ -7,6 +7,7 @@ import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.NodeList;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.PropType;
import com.eliasmueller.llamamod.editor.model.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.cmd.Values;
@@ -15,6 +16,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -154,6 +156,8 @@ final class SceneDiff {
Map<String, Object> nodes = (Map<String, Object>) json.getOrDefault("nodes", Map.of());
// 1. removals, 2. create added nodes (all kinds, so references resolve), 3. set values
List<Map.Entry<Node, Map<String, Object>>> fill = new ArrayList<>();
// per-node problems, named once the level order is final (readable names are numbered by it)
List<Map.Entry<Node, String>> nodeWarn = new ArrayList<>();
for (NodeKind k : Scene.LIST_KINDS) {
Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix());
if (d == null) {
@@ -162,7 +166,7 @@ final class SceneDiff {
for (Object id : (List<Object>) d.getOrDefault("removed", List.of())) {
Node n = s.node((String) id);
if (n == null) {
warn.add("removed " + id + " does not exist in the base level");
warn.add("The project removes " + aKind(k) + " that is not in the base level – ignored");
} else {
s.remove(n);
}
@@ -171,7 +175,7 @@ final class SceneDiff {
Map<String, Object> m = (Map<String, Object>) o;
String id = (String) m.get("id");
if (s.node(id) != null) {
warn.add("added node " + id + " already exists – skipped");
nodeWarn.add(Map.entry(s.node(id), "the project adds " + aKind(k) + " in its place – skipped"));
continue;
}
BrushKind bk = k == NodeKind.BRUSH ? BrushKind.valueOf((String) m.get("brushKind")) : null;
@@ -183,7 +187,7 @@ final class SceneDiff {
for (Map.Entry<String, Object> e : ch.entrySet()) {
Node n = s.node(e.getKey());
if (n == null) {
warn.add("changed " + e.getKey() + " does not exist in the base level");
warn.add("The project changes " + aKind(k) + " that is not in the base level – skipped");
continue;
}
fill.add(Map.entry(n, (Map<String, Object>) e.getValue()));
@@ -198,6 +202,12 @@ final class SceneDiff {
for (Map.Entry<Node, Map<String, Object>> e : fill) {
Node n = e.getKey();
Set<String> known = new HashSet<>(List.of("id", "brushKind"));
// array runs are relative to the base value: read them all before a setter clears one
// (setting a model's vertices clears its header)
Map<String, Object> before = new HashMap<>();
for (Prop p : n.props()) {
before.put(p.name(), p.get(n));
}
for (Prop p : n.props()) {
known.add(p.name());
if (!e.getValue().containsKey(p.name()) || !editable(p)) {
@@ -211,17 +221,17 @@ final class SceneDiff {
&& pm.containsKey("png")) {
byte[] png = assets.get((String) pm.get("png"));
if (png == null) {
warn.add(n.id() + ": page image " + pm.get("png") + " missing");
nodeWarn.add(Map.entry(n, "page image " + pm.get("png") + " missing"));
continue;
}
v = Images.pagePixels(png, pg.width(), pg.height());
} else {
v = NodeCodec.decode(p, j, s, p.get(n));
v = NodeCodec.decode(p, j, s, before.get(p.name()));
}
try {
p.setRaw(n, v);
} catch (IllegalArgumentException ex) {
warn.add(n.id() + "." + p.name() + ": " + ex.getMessage());
nodeWarn.add(Map.entry(n, propLabel(p) + ": " + ex.getMessage()));
}
}
for (Map.Entry<String, Object> f : e.getValue().entrySet()) {
@@ -254,5 +264,20 @@ final class SceneDiff {
for (var st : s.strats()) {
st.setTypeName(s.catalog().nameOf(st.code()));
}
ReadableNames names = ReadableNames.of(s);
for (Map.Entry<Node, String> w : nodeWarn) {
Node n = w.getKey();
warn.add((n.scene() == s ? names.nameOf(n) : ReadableNames.name(n)) + ": " + w.getValue());
}
}
/** "an object", "a tile model". */
private static String aKind(NodeKind k) {
String l = k.label().toLowerCase(Locale.ROOT);
return ("aeiou".indexOf(l.charAt(0)) >= 0 ? "an " : "a ") + l;
}
private static String propLabel(Prop p) {
return p.label() == null || p.label().isEmpty() ? p.name() : p.label();
}
}
@@ -52,6 +52,7 @@ final class MeshImportDialog extends JDialog {
private final Preview preview = new Preview();
private final JLabel info = new JLabel();
private final JLabel warn = new JLabel();
private final JLabel fitHint = GeoWidgets.hint(" ");
private final JComboBox<String> up =
new JComboBox<>(new String[] {"Y up (glTF, most OBJ)", "Z up (some CAD exports)"});
private final JSpinner unit;
@@ -100,6 +101,7 @@ final class MeshImportDialog extends JDialog {
GeoWidgets.row(new JLabel("Up axis"), up),
GeoWidgets.row(new JLabel("Unit scale (file units → cells)"), unit),
GeoWidgets.row(new JLabel("Largest side"), fit, new JLabel("cells"), fitB),
fitHint,
GeoWidgets.row(new JLabel("Turn"), turn, mirror),
GeoWidgets.row(ground)));
form.add(GeoWidgets.section(
@@ -196,22 +198,50 @@ final class MeshImportDialog extends JDialog {
if (m == null) {
return;
}
double[] b = m.bounds();
double size = Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]));
if (size <= 0) {
return;
double us = fitScale(((Number) unit.getValue()).doubleValue(), m.positions(), cells);
if (us > 0) {
unit.setValue(us);
}
double us = ((Number) unit.getValue()).doubleValue() * cells / size;
unit.setValue(us);
}
/**
* Unit scale that makes the largest side of the mesh's main mass ({@link Preview#focusBounds}) {@code
* cells} long; 0 for an empty or flat-point mesh. {@code pos} is in cells at the current {@code unitScale}.
*/
static double fitScale(double unitScale, double[] pos, double cells) {
double size = largestSide(Preview.focusBounds(pos));
return size > 0 ? unitScale * cells / size : 0;
}
private static double largestSide(double[] b) {
return Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]));
}
/** Hint shown under "Largest side" when far-off pieces are left out of Fit; empty otherwise. */
static String fitHintText(MeshData m) {
double[] f = Preview.focusBounds(m.positions());
double[] b = m.bounds();
if (largestSide(f) >= largestSide(b) - 1e-9) {
return "";
}
return String.format(
Locale.ROOT,
"Fit ignores far-off stray pieces: main part %.2f × %.2f × %.2f cells.",
f[3] - f[0],
f[4] - f[1],
f[5] - f[2]);
}
private void update() {
MeshData m = asset.mesh();
if (m == null) {
info.setText("(no mesh)");
fitHint.setText(" ");
return;
}
double[] b = m.bounds();
String fh = fitHintText(m);
fitHint.setText(fh.isEmpty() ? " " : fh);
int textured = 0;
for (MeshData.Material mt : m.materialList()) {
if (mt.image() != null) {
@@ -444,7 +474,8 @@ final class MeshImportDialog extends JDialog {
/**
* Bounds {x0, y0, z0, x1, y1, z1} of the mesh's main mass: per axis the vertices within 2.5
* interquartile ranges of the middle half (Tukey fences); the full bounds when they are not much
* interquartile ranges of the middle half (Tukey fences), minus a smaller vertex group beyond a wide
* gap (a stray triangle on a low-poly model); the full bounds when they are not much
* larger (far-off pieces such as a stray triangle or a huge ground plane would otherwise shrink the
* model to a dot).
*/
@@ -473,6 +504,30 @@ final class MeshImportDialog extends JDialog {
while (v[hi] > q3 + fence) {
hi--;
}
// low-poly models: a stray piece can hold a quarter of the vertices and pass the fences –
// cut off the smaller side of a wide gap (fewer than half the vertices of the other side)
for (int pass = 0; pass < 8 && hi > lo; pass++) {
int at = lo;
for (int i = lo; i < hi; i++) {
if (v[i + 1] - v[i] > v[at + 1] - v[at]) {
at = i;
}
}
double gap = v[at + 1] - v[at];
int left = at - lo + 1, right = hi - at;
double keep = left >= right ? v[at] - v[lo] : v[hi] - v[at + 1];
if (gap <= 0.5 * (v[hi] - v[lo])
|| keep <= 0
|| gap <= 2 * keep
|| 2 * Math.min(left, right) > Math.max(left, right)) {
break;
}
if (left >= right) {
hi = at;
} else {
lo = at + 1;
}
}
robust[axis] = v[lo];
robust[axis + 3] = v[hi];
far |= v[n - 1] - v[0] > 1.5 * (robust[axis + 3] - robust[axis]) + 1e-9;
@@ -150,4 +150,23 @@ class ProjectRoundTripTest {
assertEquals(0, q.scene().tiles().size());
assertTrue(q.scene().listOf(NodeKind.STRAT).isEmpty());
}
/**
* An imported levelbuild WAD as an edit project (llamamod --editor-import): model headers change by
* runs while the vertices change too – the vertex setter clears the header, the runs still apply.
*/
@Test
void importedLevelbuildWadLoadsBack() throws IOException {
Bases cd = cd();
com.eliasmueller.llamamod.levelbuild.LevelBuilder lb = new com.eliasmueller.llamamod.levelbuild.LevelBuilder(
cd.wad("t1l1m002"), rel -> null, "Start", 1, Map.of());
lb.floor(6, 6, 30, 30, 1.0, "grass");
lb.player(16, 12, 0);
Project p = new Project(
Templates.original(cd, "t1l1m002"), WadImporter.importWad(lb.build(), "t1l1m002", cd::fileOrNull));
byte[] wad = WadBuilder.build(p.scene());
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertTrue(q.warnings().isEmpty(), q.warnings().toString());
assertArrayEquals(wad, WadBuilder.build(q.scene()));
}
}
@@ -0,0 +1,39 @@
package com.eliasmueller.llamamod.editor.io;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class SceneDiffWarningsTest {
@Test
void loadWarningsUseReadableNamesNotIds() {
Scene s = new Scene();
s.add(Scene.createWithId(NodeKind.LIGHT, "light:1", null), -1);
s.add(Scene.createWithId(NodeKind.LIGHT, "light:2", null), -1);
Map<String, Object> json = Map.of(
"nodes",
Map.of(
"light",
Map.of(
"removed", List.of("light:99"),
"added", List.of(Map.of("id", "light:2")),
"changed", Map.of("light:77", Map.of()))));
List<String> warn = new ArrayList<>();
SceneDiff.apply(s, json, Map.of(), new SceneDiff.Unknown(), warn);
assertEquals(
List.of(
"The project removes a light that is not in the base level – ignored",
"The project changes a light that is not in the base level – skipped",
"Light 2: the project adds a light in its place – skipped"),
warn);
for (String w : warn) {
assertFalse(w.contains("light:"), w);
}
}
}
@@ -53,4 +53,47 @@ class PaletteAndPreviewTest {
double[] g = MeshImportDialog.Preview.focusBounds(small);
assertArrayEquals(new double[] {0, 0, 0, 2, 1, 2}, g, 1e-9);
}
@Test
void fitUsesTheMainMassAndHintsAtStrayPieces() {
// a 1-cell cube of 200 vertices plus one stray vertex 500 cells away
double[] pos = new double[3 * 201];
for (int i = 0; i < 200; i++) {
pos[3 * i] = (i % 2);
pos[3 * i + 1] = (i / 2 % 2);
pos[3 * i + 2] = (i / 4 % 2);
}
pos[600] = 500;
// largest side of the main part is 1 cell: Fit to 4 cells scales by 4, not by 4/500
assertEquals(4 * 0.5, MeshImportDialog.fitScale(0.5, pos, 4), 1e-9);
int[] tris = new int[] {0, 1, 2};
var stray = new com.eliasmueller.llamamod.editor.model.MeshData(
pos, tris, new double[0], new int[1], new int[0], java.util.List.of());
assertTrue(MeshImportDialog.fitHintText(stray).startsWith("Fit ignores far-off stray pieces"));
double[] cube = java.util.Arrays.copyOf(pos, 600);
assertEquals(2, MeshImportDialog.fitScale(1, cube, 2), 1e-9);
var clean = new com.eliasmueller.llamamod.editor.model.MeshData(
cube, tris, new double[0], new int[1], new int[0], java.util.List.of());
assertEquals("", MeshImportDialog.fitHintText(clean));
assertEquals(0, MeshImportDialog.fitScale(1, new double[0], 2));
// low-poly: a 2×1×3 box (8 vertices) and a stray triangle 400 cells away (3 of 11 vertices)
double[] low = new double[3 * 11];
for (int i = 0; i < 8; i++) {
low[3 * i] = i % 2 * 2;
low[3 * i + 1] = i / 2 % 2;
low[3 * i + 2] = i / 4 * 3;
}
low[24] = 400;
low[27] = 401;
low[30] = 400;
low[31] = 1;
assertArrayEquals(new double[] {0, 0, 0, 2, 1, 3}, MeshImportDialog.Preview.focusBounds(low), 1e-9);
assertEquals(4.0 / 3, MeshImportDialog.fitScale(1, low, 4), 1e-9);
// a wedge (6 vertices on x = 0, 2 on x = 1) is not cut
double[] wedge = {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 2, 0, 0, 2, 1, 1, 0, 0, 1, 0, 1};
assertArrayEquals(new double[] {0, 0, 0, 1, 2, 1}, MeshImportDialog.Preview.focusBounds(wedge), 1e-9);
// two equal towers far apart stay together
double[] towers = {0, 0, 0, 1, 5, 1, 0, 5, 0, 1, 0, 1, 20, 0, 0, 21, 5, 1, 20, 5, 0, 21, 0, 1};
assertArrayEquals(new double[] {0, 0, 0, 21, 5, 1}, MeshImportDialog.Preview.focusBounds(towers), 1e-9);
}
}