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]);