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

@@ -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);
}
}