mesh files use game space (right-handed, y up): obj/gltf import and export no longer mirror models, per-mesh handedness keeps meshes of old projects as they were, python tools follow (--export-space for the old files)

This commit is contained in:
MineTec committed 2026-11-07 12:48:43 +01:00
1 parent c4fa500198
commit 4a58910fba
26 files changed
+525 -96

No files matched your search

@@ -28,8 +28,10 @@ import java.util.function.Function;
* texture, optional vertex colours "v x y z r g b") and glTF 2.0 (.gltf with external or embedded
* buffers/images, .glb; node transforms, base colour factor/texture, COLOR_0, doubleSided).
* Files are copied into the project assets; the mesh is parsed from there, so a project is
* self-contained. Axes: files are y-up right-handed (or z-up, {@code MeshAsset.upAxis}); 1 file
* unit = {@code unitScale} cells. In game space z is mirrored (the result is not mirrored).
* self-contained. Axes: files are right-handed, y up (or z up, {@code MeshAsset.upAxis}); file
* (x, y, z) = game (x, y, z) (z up: game (x, z, −y)), so the model looks in game as in Blender; 1
* file unit = {@code unitScale} cells. Assets with handedness "left" (imported before 2026-11-07)
* keep the old reading: file = export space (x, y, −z_game), i.e. mirrored.
*/
public final class MeshImport {
/** Diffuse colour of materials without one. */
@@ -63,7 +65,8 @@ public final class MeshImport {
String d0 = dir;
files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v));
a.setRaw("file", dir + "/" + fn);
a.setMesh(parse(fn, main, rel -> s.assets().get(d0 + "/" + rel), a.upAxis(), a.unitScale()));
a.setRaw("handedness", RIGHT);
a.setMesh(parse(fn, main, rel -> s.assets().get(d0 + "/" + rel), a.upAxis(), a.unitScale(), RIGHT));
return a;
}
@@ -98,7 +101,8 @@ public final class MeshImport {
main,
rel -> s.assets().get(dir + rel.replace('\\', '/')),
a.upAxis(),
a.unitScale());
a.unitScale(),
a.handedness());
a.setMesh(m);
return m;
}
@@ -144,10 +148,21 @@ public final class MeshImport {
return p[p.length - 1];
}
/** {@link MeshAsset#handedness()} of new imports: standard right-handed files. */
public static final String RIGHT = "right";
/** Parses a right-handed file (y or z up). */
public static MeshData parse(String name, byte[] main, Function<String, byte[]> files, String up, double scale)
throws IOException {
return parse(name, main, files, up, scale, RIGHT);
}
/** handedness "left" = legacy reading (file = export space, mirrored), see {@link MeshAsset}. */
public static MeshData parse(
String name, byte[] main, Function<String, byte[]> files, String up, double scale, String handedness)
throws IOException {
String lower = name.toLowerCase(Locale.ROOT);
Builder b = new Builder(up, scale);
Builder b = new Builder(up, scale, !"left".equals(handedness));
if (lower.endsWith(".obj")) {
obj(main, files, b);
} else if (lower.endsWith(".glb")) {
@@ -174,26 +189,44 @@ public final class MeshImport {
final List<MeshData.Material> materials = new ArrayList<>();
final String up;
final double scale;
final boolean right;
Builder(String up, double scale) {
Builder(String up, double scale, boolean right) {
this.up = up;
this.scale = scale;
this.right = right;
}
/** File coordinates (right-handed) → game space. */
/**
* File coordinates → game space: right-handed y up (x, y, z), z up (x, z, −y); legacy "left"
* negates game z (file = export space).
*/
int vertex(double x, double y, double z, Integer rgb) {
double gx = x;
double gy = "z".equals(up) ? z : y;
double gz = "z".equals(up) ? y : -z;
double gz = "z".equals(up) ? -y : z;
if (!right) {
gz = -gz;
}
pos.add(new double[] {gx * scale, gy * scale, gz * scale});
cols.add(rgb == null ? 0xffffff : rgb);
hasCols |= rgb != null;
return pos.size() - 1;
}
/**
* Triangle in file order (counter-clockwise from the front in the right-handed file). The
* right-handed → game conversion mirrors, so corners 1 and 2 swap: game/export winding as the
* builder expects (export-space cross product = outward normal).
*/
void tri(int a, int b, int c, double[] uv, int mat) {
tris.add(new int[] {a, b, c});
uvs.add(uv);
if (right) {
tris.add(new int[] {a, c, b});
uvs.add(new double[] {uv[0], uv[1], uv[4], uv[5], uv[2], uv[3]});
} else {
tris.add(new int[] {a, b, c});
uvs.add(uv);
}
mats.add(mat);
}
@@ -13,6 +13,12 @@ public final class MeshAsset extends Node {
String file = "";
/** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */
String upAxis = "y";
/**
* File handedness: "right" (OBJ/glTF standard, as Blender shows it; set by every new import) or
* "left" (imports made with 0.2.0 or earlier read file coordinates as export space, which shows the
* model mirrored; the default for assets without the field keeps saved projects unchanged).
*/
String handedness = "left";
double unitScale = 1.0;
transient MeshData mesh;
@@ -40,6 +46,18 @@ public final class MeshAsset extends Node {
n.mesh = null;
},
b -> b.choices(List.of("y", "z")))
.p(
"handedness",
"Handedness",
PropType.CHOICE,
n -> n.handedness,
(n, v) -> {
n.handedness = v;
n.mesh = null;
},
b -> b.choices(List.of("right", "left"))
.desc("right: as Blender and other 3D tools show the file; left: mirrored (meshes imported"
+ " before this option existed)"))
.dbl("unitScale", "Unit scale (file units → cells)", n -> n.unitScale, (n, v) -> {
n.unitScale = v;
n.mesh = null;
@@ -77,6 +95,10 @@ public final class MeshAsset extends Node {
return upAxis;
}
public String handedness() {
return handedness;
}
public double unitScale() {
return unitScale;
}
@@ -521,6 +521,7 @@ final class GeometryActions {
try {
MeshAsset a = com.eliasmueller.llamamod.editor.io.MeshImport.importFile(s, f);
a.setRaw("upAxis", old.upAxis());
a.setRaw("handedness", old.handedness());
a.setRaw("unitScale", old.unitScale());
a.setRaw("name", old.name());
com.eliasmueller.llamamod.editor.io.MeshImport.load(s, a);
@@ -290,8 +290,10 @@ final class MeshImportDialog extends JDialog {
/** Software preview: flat-shaded triangles, painter's algorithm, orbit by dragging. */
static final class Preview extends JPanel {
private MeshData mesh;
private double yaw = Math.toRadians(35);
private double pitch = Math.toRadians(25);
/** Starts looking down at the model's front (+z, as Blender's front view shows it). */
private double yaw = Math.toRadians(215);
private double pitch = Math.toRadians(-25);
private double turn;
private boolean mirror;
private int lx, ly;
@@ -309,7 +311,7 @@ final class MeshImportDialog extends JDialog {
@Override
public void mouseDragged(MouseEvent e) {
yaw += (e.getX() - lx) * 0.01;
yaw -= (e.getX() - lx) * 0.01;
pitch = Math.max(-1.5, Math.min(1.5, pitch + (e.getY() - ly) * 0.01));
lx = e.getX();
ly = e.getY();
@@ -375,7 +377,8 @@ final class MeshImportDialog extends JDialog {
double tx = x * ct + z * st, tz = -x * st + z * ct;
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
double ry = y * cp - rz * sp, rd = y * sp + rz * cp;
sx[v] = w2 + rx * sc;
// screen x = −rx: game space is right-handed (the game's left-handed view of export space)
sx[v] = w2 - rx * sc;
sy[v] = h2 - ry * sc;
sd[v] = rd;
cam[v] = new double[] {rx, ry, rd};
@@ -466,7 +469,7 @@ final class MeshImportDialog extends JDialog {
double tx = x * ct + z * st, tz = -x * st + z * ct;
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
double ry = y * cp - rz * sp;
return new int[] {(int) (w2 + rx * sc), (int) (h2 - ry * sc)};
return new int[] {(int) (w2 - rx * sc), (int) (h2 - ry * sc)};
}
}
@@ -49,14 +49,10 @@ 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));
JCheckBox mirror = new JCheckBox("Game orientation", ctx.prefs().getBool("gltf.gameView", false));
mirror.setToolTipText("<html>Mirror z so that Blender and viewers show the level as the game does.<br>"
+ "Off: the same axes as the OBJ export and the editor's mesh import.</html>");
JPanel boxes = new JPanel();
boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS));
boxes.add(objects);
boxes.add(hidden);
boxes.add(mirror);
JPanel acc = new JPanel(new BorderLayout(0, 6));
acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
acc.add(boxes, BorderLayout.NORTH);
@@ -77,12 +73,11 @@ 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.gameView", mirror.isSelected());
GltfExport.Geometry g;
try {
g = GltfExport.collect(
ctx.scene(),
new GltfExport.Options(objects.isSelected(), mirror.isSelected()),
new GltfExport.Options(objects.isSelected()),
n -> hidden.isSelected() && ctx.isHidden(n));
} catch (RuntimeException e) {
ctx.error("Export glTF", e);
@@ -30,8 +30,9 @@ import org.joml.Vector3f;
* triangles the editor viewport draws, baked to world space, one primitive per texture page × blend
* class × sidedness. Textures are the level's pages as PNG, the engine's vertex colours (doubled with
* clamp, times flat/translucent face colours) are COLOR_0, materials are unlit
* (KHR_materials_unlit, optional). Coordinates are export space like the OBJ export and the mesh
* importer (y up, z = −game z, 1 unit = 1 cell); {@link Options#gameView} mirrors z instead.
* (KHR_materials_unlit, optional). Coordinates are game space (right-handed, y up, 1 unit = 1 cell;
* export space with z negated and the winding reversed), so viewers show the level as the game does
* and the mesh importer reads it back unchanged.
*
* <p>Two steps: {@link #collect} reads the scene (EDT), {@link Geometry#glb} / {@link Geometry#gltf}
* encode the snapshot (any thread).
@@ -39,12 +40,8 @@ import org.joml.Vector3f;
public final class GltfExport {
private GltfExport() {}
/**
* objects: also the models of placed objects (strats) as the viewport shows them; gameView:
* mirror z so that right-handed viewers show the level as the game does (re-importing such a
* file into the editor gives mirrored geometry).
*/
public record Options(boolean objects, boolean gameView) {}
/** objects: also the models of placed objects (strats) as the viewport shows them. */
public record Options(boolean objects) {}
/** One glTF primitive: unique vertices + triangle indices. */
static final class Prim {
@@ -383,10 +380,8 @@ public final class GltfExport {
ri.transform.transformPosition(
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]));
nm.transform(n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]));
if (o.gameView()) {
p.z = -p.z;
n.z = -n.z;
}
p.z = -p.z;
n.z = -n.z;
int c = ri.vertexColors != null
&& (fl & RenderMesh.NO_VERTEX_COLOR) == 0
&& v < ri.vertexColors.length
@@ -396,14 +391,12 @@ public final class GltfExport {
tri[k] = new float[] {p.x, p.y, p.z, n.x, n.y, n.z, m.uvs[6 * t + 2 * k], m.uvs[6 * t + 2 * k + 1]};
rgba[k] = c;
}
if (o.gameView()) {
float[] x = tri[1];
tri[1] = tri[2];
tri[2] = x;
int y = rgba[1];
rgba[1] = rgba[2];
rgba[2] = y;
}
float[] x = tri[1];
tri[1] = tri[2];
tri[2] = x;
int y = rgba[1];
rgba[1] = rgba[2];
rgba[2] = y;
// unit normals (glTF requires them): face normal where the vertex normal is zero
e1.set(tri[1][0] - tri[0][0], tri[1][1] - tri[0][1], tri[1][2] - tri[0][2]);
e2.set(tri[2][0] - tri[0][0], tri[2][1] - tri[0][1], tri[2][2] - tri[0][2]);
@@ -0,0 +1,67 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.MeshAsset;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import java.nio.file.Path;
/**
* Not a unit test: test level for the mesh import handedness. A plaza with four imported "F"
* letters (front face red, +x end blue) at game z 13 (scale 0.4): OBJ and glTF as imported, then both turned
* 180°. The llama starts at (16, 0, 4) facing game +z. Args: CD folder, f.obj, f.glb, output
* .llamalevel, chapter.
*/
public final class HandednessDemo {
private HandednessDemo() {}
static void mesh(Scene s, MeshAsset a, double x, double z, double rotY, String name) {
Brush b = s.createBrush(BrushKind.MESH);
b.setRaw("asset", a);
b.setRaw("x", x);
b.setRaw("y", 0.0);
b.setRaw("z", z);
b.setRaw("rotY", rotY);
b.setRaw("scale", 0.4);
b.setRaw("name", name);
new AddNode(b).apply(s);
}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
Project pr = Project.create(cd, "new", Templates.DEFAULT_BASE);
Scene s = pr.scene();
s.settings().setRaw("name", "Handedness Test");
s.settings().setRaw("targetChapter", Integer.parseInt(a[4]));
NoCollisionDemo.box(s, 2, 1, 30, 26, -1, 0, null);
MeshAsset obj = MeshImport.importFile(s, Path.of(a[1]));
new AddNode(obj).apply(s);
MeshAsset glb = MeshImport.importFile(s, Path.of(a[2]));
new AddNode(glb).apply(s);
mesh(s, obj, 10, 13, 0, "F obj");
mesh(s, glb, 12.5, 13, 0, "F glb");
mesh(s, obj, 19.5, 13, 180, "F obj turned");
mesh(s, glb, 22, 13, 180, "F glb turned");
double px = 16, pz = 4, yaw = 0;
Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw);
int k = 0;
for (Command c : b.commands()) {
if (c instanceof AddNode an && an.node() instanceof PathNode p) {
p.setRaw("x", Edits.fixed(px));
p.setRaw("y", Edits.fixed(k == 0 ? 1.5 : 0));
p.setRaw("z", Edits.fixed(k == 0 ? pz - 8 : pz));
k++;
}
}
b.apply(s);
WadBuilder.Result r = WadBuilder.buildWithReport(s);
System.out.println("built " + r.wad().length + " bytes, " + r.stats() + ", warnings " + r.warnings());
ProjectIO.save(pr, Path.of(a[3]), cd);
System.out.println("saved " + a[3]);
}
}
@@ -0,0 +1,155 @@
package com.eliasmueller.llamamod.editor.io;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.model.MeshAsset;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* Files are right-handed: an asymmetric triangle (long leg along +x, counter-clockwise seen from
* the front) must keep its coordinates in game space and face the same way, so the game (which
* shows game space right-handed) shows it as Blender does.
*/
class MeshHandednessTest {
/** Right triangle in the xy plane, front +z (y up file). */
static final String OBJ_Y_UP = "v 0 0 0\nv 2 0 0\nv 0 1 0\nf 1 2 3\n";
/** The same triangle as Blender sees it with z up: in the xz plane, front −y. */
static final String OBJ_Z_UP = "v 0 0 0\nv 2 0 0\nv 0 0 1\nf 1 2 3\n";
static MeshData obj(String text, String up, String handedness) throws IOException {
return MeshImport.parse("t.obj", text.getBytes(StandardCharsets.UTF_8), f -> null, up, 1, handedness);
}
static double[][] corners(MeshData m, int t) {
double[][] p = new double[3][];
for (int k = 0; k < 3; k++) {
int v = m.triangles()[3 * t + k];
p[k] = new double[] {m.positions()[3 * v], m.positions()[3 * v + 1], m.positions()[3 * v + 2]};
}
return p;
}
/** Game-space front normal of a mesh triangle as the brush compiler sees it. */
static double[] front(MeshData m, int t) {
return BrushCompiler.frontNormal(corners(m, t));
}
static void assertRightHanded(MeshData m) {
assertEquals(1, m.triangleCount());
double[] b = m.bounds();
assertArrayEquals(new double[] {0, 0, 0, 2, 1, 0}, b, 1e-9, "game = file coordinates (y up)");
assertArrayEquals(new double[] {0, 0, 1}, front(m, 0), 1e-9, "faces game +z like the file's +z");
}
@Test
void objKeepsCoordinatesAndFacing() throws IOException {
assertRightHanded(obj(OBJ_Y_UP, "y", MeshImport.RIGHT));
assertRightHanded(obj(OBJ_Z_UP, "z", MeshImport.RIGHT));
assertRightHanded(MeshImport.parse("t.obj", OBJ_Y_UP.getBytes(StandardCharsets.UTF_8), f -> null, "y", 1));
}
@Test
void legacyAssetsStayMirrored() throws IOException {
for (String[] c : new String[][] {{OBJ_Y_UP, "y"}, {OBJ_Z_UP, "z"}}) {
MeshData m = obj(c[0], c[1], "left");
assertArrayEquals(new double[] {0, 0, 0, 2, 1, 0}, m.bounds(), 1e-9);
assertArrayEquals(new double[] {0, 0, -1}, front(m, 0), 1e-9, "old reading: z mirrored");
}
}
@Test
void gltfKeepsCoordinatesAndFacing() throws IOException {
assertRightHanded(MeshImport.parse("t.glb", triangleGlb(null), f -> null, "y", 1));
// a mirroring node transform (scale −1 in x) mirrors the shape but keeps the faces outward
MeshData m = MeshImport.parse("t.glb", triangleGlb("\"scale\": [-1, 1, 1]"), f -> null, "y", 1);
assertArrayEquals(new double[] {-2, 0, 0, 0, 1, 0}, m.bounds(), 1e-9);
assertArrayEquals(new double[] {0, 0, 1}, front(m, 0), 1e-9);
}
static byte[] triangleGlb(String nodeExtra) {
java.nio.ByteBuffer b = java.nio.ByteBuffer.allocate(36 + 8).order(java.nio.ByteOrder.LITTLE_ENDIAN);
for (float f : new float[] {0, 0, 0, 2, 0, 0, 0, 1, 0}) {
b.putFloat(f);
}
b.putShort((short) 0).putShort((short) 1).putShort((short) 2).putShort((short) 0);
String buf = java.util.Base64.getEncoder().encodeToString(b.array());
String json =
"""
{"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0%s}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
"buffers": [{"byteLength": 44, "uri": "data:application/octet-stream;base64,%s"}],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 6}],
"accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"}]}
"""
.formatted(nodeExtra == null ? "" : ", " + nodeExtra, buf);
return MeshImportTest.glb(json);
}
@Test
void newImportsAreMarkedRightHanded(@TempDir Path dir) throws IOException {
Files.writeString(dir.resolve("t.obj"), OBJ_Y_UP);
Scene s = new Scene();
MeshAsset a = MeshImport.importFile(s, dir.resolve("t.obj"));
assertEquals("right", a.handedness());
assertRightHanded(a.mesh());
assertEquals("left", new MeshAsset("x").handedness(), "assets without the field: old reading");
}
/** A project saved before the handedness field existed loads with the old (mirrored) reading. */
@Test
void oldProjectsDoNotFlip(@TempDir Path dir) throws IOException {
Bases cd = ProjectRoundTripTest.cd();
Project p = Project.create(cd, "new", Templates.DEFAULT_BASE);
Scene s = p.scene();
Files.writeString(dir.resolve("t.obj"), OBJ_Y_UP);
MeshAsset a = MeshImport.importFile(s, dir.resolve("t.obj"));
new AddNode(a).apply(s);
Map<String, byte[]> files = new java.util.LinkedHashMap<>(ProjectIO.unzip(ProjectIO.write(p, cd)));
String json = new String(files.get("level.json"), StandardCharsets.UTF_8);
assertTrue(json.contains("\"handedness\""), "new projects store the field");
Project q = ProjectIO.read(zip(files), cd);
MeshAsset qa = q.scene().meshAssets().getFirst();
assertEquals("right", qa.handedness());
assertRightHanded(MeshImport.load(q.scene(), qa));
String old = json.replaceAll("\"handedness\"\\s*:\\s*\"right\"\\s*,?", "");
assertFalse(old.contains("handedness"));
files.put("level.json", old.getBytes(StandardCharsets.UTF_8));
Project o = ProjectIO.read(zip(files), cd);
assertTrue(o.warnings().isEmpty(), o.warnings().toString());
MeshAsset oa = o.scene().meshAssets().getFirst();
assertEquals("left", oa.handedness());
assertArrayEquals(new double[] {0, 0, -1}, front(MeshImport.load(o.scene(), oa), 0), 1e-9);
}
static byte[] zip(Map<String, byte[]> files) throws IOException {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
try (ZipOutputStream z = new ZipOutputStream(bo)) {
for (Map.Entry<String, byte[]> e : files.entrySet()) {
z.putNextEntry(new ZipEntry(e.getKey()));
z.write(e.getValue());
z.closeEntry();
}
}
return bo.toByteArray();
}
}
@@ -129,8 +129,8 @@ class MeshImportTest {
assertTrue(s.assets().containsKey("assets/box/box.obj"));
assertTrue(s.assets().containsKey("assets/box/box.mtl"));
assertTrue(s.assets().containsKey("assets/box/check.png"));
// game z = −file z
assertEquals(-1.0, m.positions()[2], 1e-9);
// game = file coordinates (right-handed, y up); first vertex used: 8 = (−1, 1, 1)
assertEquals(1.0, m.positions()[2], 1e-9);
// reload from the assets gives the same mesh
MeshData again = MeshImport.load(s, obj);
assertArrayEquals(m.positions(), again.positions());
@@ -140,7 +140,7 @@ class MeshImportTest {
MeshData g = MeshImport.importFile(s, dir.resolve("tri.gltf")).mesh();
assertEquals(1, g.triangleCount());
assertEquals(0.5, g.positions()[1], 1e-6, "translation applied");
assertEquals(2.0, g.positions()[8], 1e-6, "z mirrored and scaled");
assertEquals(-2.0, g.positions()[8], 1e-6, "z scaled, not mirrored");
assertTrue(g.materialList().getFirst().doubleSided());
Files.write(dir.resolve("tri.glb"), glb(gltfJson()));
MeshData gb = MeshImport.importFile(s, dir.resolve("tri.glb")).mesh();
@@ -0,0 +1,80 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.BrushCompiler;
import com.eliasmueller.llamamod.editor.io.MeshImport;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** File → Export selection as OBJ writes game space (right-handed); Import mesh reads it back unchanged. */
public class ObjExportRoundTripTest {
static Bases cd() {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available (llamamod.cd)");
return Bases.fromCd(Path.of(cd));
}
@Test
void boxComesBackWithTheSameShapeAndOutwardFaces(@TempDir Path dir) throws Exception {
Scene s = Project.create(cd(), "new", "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);
Brush box = s.brushes().getLast();
MeshLibrary.Tables[] tables = new MeshLibrary.Tables[1];
List<MeshLibrary.Placed> placed = MeshLibrary.collect(s, List.<Node>of(box), tables);
Path obj = dir.resolve("box.obj");
MeshLibrary.exportObj(placed, tables[0], obj, true);
MeshData m = MeshImport.parse("box.obj", Files.readAllBytes(obj), f -> readOrNull(dir.resolve(f)), "y", 1);
double[] b = m.bounds();
assertEquals(2, b[3] - b[0], 1e-4, "x size");
assertEquals(1, b[4] - b[1], 1e-4, "y size");
assertEquals(3, b[5] - b[2], 1e-4, "z size (game z, not mirrored)");
assertTrue(b[2] > -1e-4 && b[5] < 5, "game z relative to the anchor cell: " + b[2] + ".." + b[5]);
assertOutward(m, b);
MeshData c = MeshImport.parse("c.obj", Files.readAllBytes(dir.resolve("box_collision.obj")), f -> null, "y", 1);
assertEquals(b[2], c.bounds()[2], 1e-4);
assertEquals(b[5], c.bounds()[5], 1e-4);
}
/** Every triangle's front normal points away from the box centre (winding survives the trip). */
public static void assertOutward(MeshData m, double[] b) {
double[] centre = {(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2};
for (int t = 0; t < m.triangleCount(); t++) {
double[][] p = new double[3][];
double[] c = new double[3];
for (int k = 0; k < 3; k++) {
int v = m.triangles()[3 * t + k];
p[k] = new double[] {m.positions()[3 * v], m.positions()[3 * v + 1], m.positions()[3 * v + 2]};
for (int j = 0; j < 3; j++) {
c[j] += p[k][j] / 3;
}
}
double[] n = BrushCompiler.frontNormal(p);
double d = n[0] * (c[0] - centre[0]) + n[1] * (c[1] - centre[1]) + n[2] * (c[2] - centre[2]);
assertTrue(d > 0, "triangle " + t + " faces inwards");
}
}
private static byte[] readOrNull(Path p) {
try {
return Files.readAllBytes(p);
} catch (java.io.IOException e) {
return null;
}
}
}
@@ -0,0 +1,36 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import com.eliasmueller.llamamod.editor.io.MeshImport;
import com.eliasmueller.llamamod.editor.model.MeshData;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
/** Not a unit test: paints the Import mesh dialog preview of a model to a PNG. Args: model, out.png. */
public final class PreviewShot {
private PreviewShot() {}
public static void main(String[] a) throws Exception {
Path f = Path.of(a[0]);
Path dir = f.toAbsolutePath().getParent();
MeshData m = MeshImport.parse(
f.getFileName().toString(),
Files.readAllBytes(f),
r -> {
try {
return Files.readAllBytes(dir.resolve(r));
} catch (java.io.IOException e) {
return null;
}
},
"y",
1);
MeshImportDialog.Preview p = new MeshImportDialog.Preview();
p.setSize(420, 420);
p.set(m, 0, false);
BufferedImage img = new BufferedImage(420, 420, BufferedImage.TYPE_INT_RGB);
p.paint(img.createGraphics());
ImageIO.write(img, "png", Path.of(a[1]).toFile());
}
}
@@ -200,14 +200,14 @@ class GltfExportTest {
void villageExportsAsValidGlbAndGltf() throws Exception {
Project pr = Project.create(cd(), "edit", "t1l1m001");
Scene s = pr.scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false), n -> false);
assertTrue(g.triangles() > 20000, "village triangles: " + g.triangles());
byte[] glb = g.glb();
Parsed p = parseGlb(glb);
assertEquals(g.triangles(), validate(p));
assertTrue(list(p.json(), "images").size() > 5, "texture pages");
assertTrue(list(p.json(), "materials").size() > 3);
// vertex positions cover the tiles' cells (export space: z ≤ 0)
// vertex positions cover the tiles' cells (game space: z = −export z ≥ 0)
double[] lo = {1e9, 1e9, 1e9};
double[] hi = {-1e9, -1e9, -1e9};
for (Object a : list(p.json(), "accessors")) {
@@ -227,7 +227,7 @@ class GltfExportTest {
double tx1 = drawn.stream().mapToDouble(Tile::x).max().orElseThrow();
double tz0 = drawn.stream().mapToDouble(Tile::z).min().orElseThrow();
assertTrue(lo[0] < tx0 + 1 && hi[0] > tx1 - 1, "x range " + lo[0] + ".." + hi[0]);
assertTrue(lo[2] < tz0 + 1 && hi[2] <= 3, "export-space z " + lo[2] + ".." + hi[2]);
assertTrue(hi[2] > -tz0 - 1 && lo[2] >= -3, "game-space z " + lo[2] + ".." + hi[2]);
// .gltf: same content, buffer as data URI
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(g.gltf(), StandardCharsets.UTF_8));
@@ -236,25 +236,18 @@ class GltfExportTest {
byte[] bin = Base64.getDecoder().decode(uri.substring(uri.indexOf(',') + 1));
assertEquals(g.triangles(), validate(new Parsed(j, bin)));
// round trip through the editor's own importer (file space = export space, game z = −z)
// round trip through the editor's own importer (file space = game space)
MeshData md = MeshImport.parse("v.glb", glb, f -> null, "y", 1);
assertEquals(g.triangles(), md.triangleCount());
double[] b = md.bounds();
assertEquals(lo[0], b[0], 1e-3);
assertEquals(hi[0], b[3], 1e-3);
assertEquals(-hi[2], b[2], 1e-3);
assertEquals(-lo[2], b[5], 1e-3);
assertEquals(lo[2], b[2], 1e-3);
assertEquals(hi[2], b[5], 1e-3);
assertTrue(md.materialList().stream().filter(m -> m.image() != null).count() > 5, "textures read back");
assertNotNull(md.colors(), "vertex colours read back");
// game orientation: z mirrored, same triangles
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
assertEquals(g.triangles(), m.triangles());
double[] mb = MeshImport.parse("m.glb", m.glb(), f -> null, "y", 1).bounds();
assertEquals(-b[5], mb[2], 1e-3);
assertEquals(-b[2], mb[5], 1e-3);
assertEquals(b[0], mb[0], 1e-3);
GltfExport.Geometry o = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
GltfExport.Geometry o = GltfExport.collect(s, new GltfExport.Options(true), n -> false);
assertTrue(o.triangles() > g.triangles(), "objects add triangles");
assertEquals(o.triangles(), validate(parseGlb(o.glb())));
}
@@ -264,7 +257,7 @@ class GltfExportTest {
Scene s = Project.create(cd(), "new", "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);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false), n -> false);
Parsed p = parseGlb(g.glb());
assertEquals(g.triangles(), validate(p));
assertTrue(g.triangles() >= 10, "box faces: " + g.triangles());
@@ -273,23 +266,26 @@ class GltfExportTest {
assertEquals(10, b[0], 1e-3);
assertEquals(12, b[3], 1e-3);
assertEquals(1, b[4], 1e-3);
assertEquals(10, b[2], 1e-3);
assertEquals(13, b[5], 1e-3);
com.eliasmueller.llamamod.editor.tools.geometry.ObjExportRoundTripTest.assertOutward(md, b);
// hidden brush left out
GltfExport.Geometry none = GltfExport.collect(s, new GltfExport.Options(false, false), n -> true);
GltfExport.Geometry none = GltfExport.collect(s, new GltfExport.Options(false), n -> true);
assertEquals(0, none.triangles());
}
@Test
void excludedTilesAreLeftOut() throws Exception {
Scene s = Project.create(cd(), "edit", "t1l1m001").scene();
int all = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
int all =
GltfExport.collect(s, new GltfExport.Options(false), n -> false).triangles();
Tile t = s.tiles().stream()
.filter(x -> x.model() != null && x.model().faceCount() > 4)
.findFirst()
.orElseThrow();
t.prop("excluded").setRaw(t, true);
int less = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
int less =
GltfExport.collect(s, new GltfExport.Options(false), n -> false).triangles();
assertTrue(less < all && less >= all - t.model().faceCount(), all + " → " + less);
}
}
@@ -15,11 +15,9 @@ public final class GltfSample {
Path out = Files.createDirectories(Path.of(a[1]));
for (int i = 2; i < a.length; i++) {
Scene s = Project.create(cd, "edit", a[i]).scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(true), n -> false);
Files.write(out.resolve(a[i] + ".glb"), g.glb());
Files.write(out.resolve(a[i] + ".gltf"), g.gltf());
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
Files.write(out.resolve(a[i] + "_game.glb"), m.glb());
System.out.println(a[i] + ": " + g.triangles() + " triangles, " + g.vertices() + " vertices, "
+ g.primitives() + " primitives");
}