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:
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]);
|
||||
|
||||
Reference in new issue
Block a user