diff --git a/docs/manual/img/geo_gltf_export.jpg b/docs/manual/img/geo_gltf_export.jpg index 0b81f9b..634637a 100644 Binary files a/docs/manual/img/geo_gltf_export.jpg and b/docs/manual/img/geo_gltf_export.jpg differ diff --git a/docs/manual/tutorial-blender.md b/docs/manual/tutorial-blender.md index 86ef3c3..b38659f 100644 --- a/docs/manual/tutorial-blender.md +++ b/docs/manual/tutorial-blender.md @@ -111,6 +111,7 @@ house or a brush course: |---|---| | model is huge or tiny | wrong unit scale – use **Largest side** + **Fit** | | model lies on its side | switch **Up axis** | +| model is mirrored (text reads backwards) | it was imported with LlamaMod 0.2.0 or earlier – set **Handedness** of the mesh (Assets → **Imported**, Inspector) to **right** | | no textures | the MTL or the textures were not next to the OBJ – export with *Path Mode: Copy*, or use `.glb` | | texture looks smeared | UV layout is not rectangular – reset UVs of quads, raise **UV precision** | | llama falls through | collision is **None** or **Simplified** is not what you wanted – choose **From the mesh**; check with **C** that the surface is coloured; recalculate normals in Blender | diff --git a/notes/editor-render.md b/notes/editor-render.md index 1db9526..174f40d 100644 --- a/notes/editor-render.md +++ b/notes/editor-render.md @@ -164,6 +164,10 @@ Ctrl inverts `enabled` while dragging. * Rotate gizmo: the edge-on fallback uses the screen angle with the mirrored sign. * Standard views: **top** = x to the right, game z (grid rows) downwards (cell (0,0) top left, as the MAP/region grid is laid out; yaw π); **front** looks along game −z (x right); **side** looks along −x. +* Files (Import mesh, OBJ and glTF export, the Python geom tools) are right-handed y-up **game + space** (= export space with z negated and the winding reversed), so they look the same in + Blender as in the editor and the game (2026-11-07; before, they were export space and appeared + mirrored – see editor-tools-geometry.md "Handedness"). * Spaces: render/export space = (x, y, −z_game); model and inspector values (strats, waypoints, zones, lights, collision) are game space, tiles store export floats. LGHT point lights/directions are stored in game space (engine negates z on load). Strat yaw: `rotateY(−ry·2π/4096)` in export diff --git a/notes/geometry.md b/notes/geometry.md index 2ca52c8..43d084e 100644 --- a/notes/geometry.md +++ b/notes/geometry.md @@ -28,9 +28,18 @@ the spatial partition is the grid itself (cell lists) + a 32-zone visibility tab ## 2. Coordinate spaces [certain] -* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): right-handed, **y up**, - grid cells lie at x ∈ [0,W), **z ∈ (−H, 0]**. The OBJ export uses this space. +* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): **y up**, grid cells lie at + x ∈ [0,W), **z ∈ (−H, 0]**. The engine draws it with a Direct3D camera, i.e. **left-handed**: + a right-handed tool shows export-space data mirrored (see editor-render.md). * **Game space** (MAP fixed point "B", collision, strats): (x, y, −z), 20.12 fixed point (4096 = 1). + As the game shows it, game space is **right-handed** (y up). +* **File space** (OBJ/glTF of all tools, since 2026-11-07) = game space: right-handed, y up, + z > 0 in the grid, so Blender shows a level the same way round as the game and a model from + Blender appears in game as modelled. Converting export ↔ file negates z and reverses the corner + order (first corner kept: export (0,1,2) ↔ file (0,2,1); `geomlib.to_file/file_order`). + Used by `geom_export`/`geom_import`/`geom_demo` (`--export-space` = old raw export space, z < 0), + the editor's Import mesh, OBJ export and glTF export. Verified in game and Blender with an + asymmetric "F" (`img/editor/handed_*.jpg`, editor-tools-geometry.md). The renderer converts B back: pos = (Bx, By, −Bz)/4096, angle = rot·2π/4096. * Tile transform (local vertex v → world, export space), a = rot/4096·2π: `X = x + vx·cos a − vz·sin a ; Y = y + vy ; Z = z + vx·sin a + vz·cos a` @@ -197,9 +206,11 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the * Whether the player start point/camera is coupled to geometry (objects.md). ## 10. Tools -* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, vertex colours, one +* OBJ files of all three tools are file space = game space (section 2); `--export-space` on + `geom_export`/`geom_import` = the old raw export space (z < 0). +* `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png (textured software renderer). -* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]` +* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]` * `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads. * `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision). diff --git a/notes/img/editor/geo_gltf_export.jpg b/notes/img/editor/geo_gltf_export.jpg index 0b81f9b..634637a 100644 Binary files a/notes/img/editor/geo_gltf_export.jpg and b/notes/img/editor/geo_gltf_export.jpg differ diff --git a/notes/img/editor/handed_after.jpg b/notes/img/editor/handed_after.jpg new file mode 100644 index 0000000..5b41134 Binary files /dev/null and b/notes/img/editor/handed_after.jpg differ diff --git a/notes/img/editor/handed_before.jpg b/notes/img/editor/handed_before.jpg new file mode 100644 index 0000000..9e486f8 Binary files /dev/null and b/notes/img/editor/handed_before.jpg differ diff --git a/notes/levelbau.md b/notes/levelbau.md index 86fb1b1..d9d6823 100644 --- a/notes/levelbau.md +++ b/notes/levelbau.md @@ -265,8 +265,8 @@ X display → own `Xvfb :7` (never :0, :5 belongs to lamatest). Procedure (scrip 1. `geom_export.py out/testwelt.wad GE` → level.obj/mtl + page_NN.png 2. Strat positions: run the level script with `levelbuild.Level` (without `L.build`), `L.strats` as JSON 3. `DISPLAY=:7 STRATS=strats.json VIEWS=schraeg,schraeg2,oben blender -b -P render_level.py -- GE r 64`: - own OBJ reader (the 3.0 importer does not know vertex colours), mirroring OBJ→game (y_blender = - −game z, flip winding), material = texture (closest) × vertex colour, a little emission, + own OBJ reader (the 3.0 importer does not know vertex colours), OBJ (= game space since + 2026-11-07) → Blender z up (y_blender = −game z, winding kept), material = texture (closest) × vertex colour, a little emission, sun + sky, markers for strats (coins gold, llama start orange cone, gallery objects in row colour, animals white, exit purple ring); writes PNG + JSON per view with image coordinates of the area names. diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java b/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java index 89f5d2e..5164dac 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java @@ -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 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 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 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); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java index 6adb176..c7b4220 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java @@ -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; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java index 07dea88..bfbd810 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java @@ -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); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java index f46bae8..c5a031f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java @@ -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)}; } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java index 7e5c75d..70c7667 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfEditorPlugin.java @@ -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("Mirror z so that Blender and viewers show the level as the game does.
" - + "Off: the same axes as the OBJ export and the editor's mesh import."); 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); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java index 3d2cc01..b88d0d5 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExport.java @@ -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. * *

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]); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java new file mode 100644 index 0000000..f94d8dd --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java @@ -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]); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/MeshHandednessTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/MeshHandednessTest.java new file mode 100644 index 0000000..30e07b1 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/MeshHandednessTest.java @@ -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 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 files) throws IOException { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + try (ZipOutputStream z = new ZipOutputStream(bo)) { + for (Map.Entry e : files.entrySet()) { + z.putNextEntry(new ZipEntry(e.getKey())); + z.write(e.getValue()); + z.closeEntry(); + } + } + return bo.toByteArray(); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/MeshImportTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/MeshImportTest.java index afaf1c6..897c1a9 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/io/MeshImportTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/MeshImportTest.java @@ -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(); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/ObjExportRoundTripTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/ObjExportRoundTripTest.java new file mode 100644 index 0000000..dd48edc --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/ObjExportRoundTripTest.java @@ -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 placed = MeshLibrary.collect(s, List.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; + } + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewShot.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewShot.java new file mode 100644 index 0000000..175c23d --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PreviewShot.java @@ -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()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java index d7d597f..40a27ed 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfExportTest.java @@ -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); } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java index 87d4334..7b0a82c 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/gltf/GltfSample.java @@ -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"); } diff --git a/tools/geom_demo.py b/tools/geom_demo.py index d2d360a..667c371 100644 --- a/tools/geom_demo.py +++ b/tools/geom_demo.py @@ -33,7 +33,7 @@ class ObjW: self.f.append((tex, mat, [base + k for k in range(4)])) def box(self, x0, z0, x1, z1, y0, y1, top, side): - # coordinates: x0z1 (negative z = further into the grid) + # export space: x0z1 (negative z = further into the grid); save() writes game space self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top) for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]: # wall from a to b, outside = clockwise @@ -45,12 +45,12 @@ class ObjW: def save(self, path): with open(path, 'w') as fo: for p in self.v: - fo.write('v %.4f %.4f %.4f 1 1 1\n' % p) + fo.write('v %.4f %.4f %.4f 1 1 1\n' % G.to_file(p)) for u, v in self.vt: fo.write('vt %.6f %.6f\n' % (u, 1 - v)) for tex, mat, ids in self.f: fo.write(f'g demo_mat{mat:02d}\nusemtl tex_{tex}\n') - fo.write('f ' + ' '.join(f'{i + 1}/{i + 1}' for i in ids) + '\n') + fo.write('f ' + ' '.join(f'{i + 1}/{i + 1}' for i in G.file_order(ids)) + '\n') def main(): diff --git a/tools/geom_export.py b/tools/geom_export.py index 1169440..e712125 100644 --- a/tools/geom_export.py +++ b/tools/geom_export.py @@ -11,7 +11,9 @@ Writes to outdir: objects.obj (--objects) static STPC object models side by side preview_top.png, preview_persp.png textured software renderings for checking -Coordinates: export space = MAP floats (y up, right-handed, z < 0). Game space = (x, y, -z). +Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as +Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes +the raw export space (MAP floats, z < 0; mirrored in other tools), the convention before 2026-11-07. Format: notes/geometry.md """ import argparse @@ -61,7 +63,7 @@ def flat_color(t): return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255) -def write_obj(path, tris, texs, mtlname): +def write_obj(path, tris, texs, mtlname, raw=False): with open(path, 'w') as fo: fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n') fo.write(f'mtllib {mtlname}\n') @@ -77,10 +79,11 @@ def write_obj(path, tris, texs, mtlname): if mat != cur_mat: fo.write(f'usemtl {mat}\n') cur_mat = mat - for p, c in zip(P, C): - fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*p, *c)) - for u, v in uv: - fo.write('vt %.6f %.6f\n' % (u, 1 - v)) + order = [0, 1, 2] if raw else G.file_order(range(3)) + for k in order: + fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*(P[k] if raw else G.to_file(P[k])), *C[k])) + for k in order: + fo.write('vt %.6f %.6f\n' % (uv[k][0], 1 - uv[k][1])) fo.write(f'f {vi}/{vi} {vi + 1}/{vi + 1} {vi + 2}/{vi + 2}\n') vi += 3 @@ -168,12 +171,16 @@ def main(): ap.add_argument('--objects', action='store_true') ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews') ap.add_argument('--size', default='1200x900') + ap.add_argument('--export-space', action='store_true', + help='raw export space (z < 0, mirrored in Blender) instead of game space') a = ap.parse_args() + out = (lambda p: p) if a.export_space else G.to_file + order = (lambda s: list(s)) if a.export_space else G.file_order os.makedirs(a.outdir, exist_ok=True) ch, wf, trak, mp = G.load_level(a.wad) pages, texs = G.parse_text(G.wad_get(ch, 'TEXT')) tris = world_tris(trak, mp, texs) - write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl') + write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl', a.export_space) write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages)) print(f'{a.wad}: WFPC={wf:#x} TRAK models={len(trak)} tiles={mp["N"]} grid={mp["W"]}x{mp["H"]} ' f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}') @@ -194,8 +201,8 @@ def main(): if nrm @ np.array(c['plane'][:3]) < 0: pts = pts[::-1] fo.write(f'g mat{c["mat"]:02d}_grp{g}\n') - for p in pts: - fo.write('v %.5f %.5f %.5f\n' % f(*p)) + for p in order(pts): + fo.write('v %.5f %.5f %.5f\n' % out(f(*p))) fo.write('f ' + ' '.join(str(vi + k) for k in range(len(pts))) + '\n') vi += len(pts) @@ -211,7 +218,7 @@ def main(): dx = ox - min(xs) fo.write(f'o stpc_model_{k:03d}\n') for v in m.verts: - fo.write('v %.5f %.5f %.5f\n' % (v[0] + dx, v[1], v[2])) + fo.write('v %.5f %.5f %.5f\n' % out((v[0] + dx, v[1], v[2]))) for fc in m.faces: t = texs[fc['tex']] if fc['tex'] < len(texs) else None if t and not (t['flags'] & 1) and t['page'] >= 0: @@ -220,7 +227,7 @@ def main(): uv = [(0, 0)] * 3; fo.write(f'usemtl flat_{fc["tex"]}\n') for u, v in uv: fo.write('vt %.6f %.6f\n' % (u, 1 - v)) - fo.write('f ' + ' '.join(f'{vi + fc["v"][j]}/{vt + j}' for j in range(3)) + '\n') + fo.write('f ' + ' '.join(f'{vi + fc["v"][j]}/{vt + j}' for j in order(range(3))) + '\n') vt += 3 vi += len(m.verts) ox += max(xs) - min(xs) + 0.5 diff --git a/tools/geom_import.py b/tools/geom_import.py index 71cb0ed..928d992 100644 --- a/tools/geom_import.py +++ b/tools/geom_import.py @@ -4,8 +4,9 @@ geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N] [--keep-tiles] [--band 6] -Input OBJ in the export space of geom_export.py (y up, 1 unit = 1 tile, z < 0 lies in the -grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1). +Input OBJ in game space as geom_export.py writes it (right-handed, y up, 1 unit = 1 tile, z > 0 +lies in the grid; grid cell (cx,cz) = (floor(x), floor(z)), must be within 0..W-1 / 0..H-1), i.e. +as Blender shows it. --export-space reads the old convention (raw export space, z < 0). Textures: the engine has NO free UVs. Each triangle shows one half of a texture rectangle (entry of the TEXT table); the corners are chosen via face flags. @@ -43,7 +44,8 @@ import geomlib as G # noqa: E402 PATTERNS = [0x800, 0x810, 0x820, 0x830, 0x000, 0x020] -def read_obj(path): +def read_obj(path, export_space=False): + """Faces and points in export space (converted from file space unless export_space).""" V, C, VT = [], [], [] faces = [] # (idx list, vt list or None, material, group) mat, grp = None, '' @@ -52,7 +54,8 @@ def read_obj(path): if not p: continue if p[0] == 'v': - V.append(tuple(float(x) for x in p[1:4])) + v = tuple(float(x) for x in p[1:4]) + V.append(v if export_space else G.to_file(v)) C.append(tuple(float(x) for x in p[4:7]) if len(p) >= 7 else None) elif p[0] == 'vt': VT.append((float(p[1]), float(p[2]))) @@ -67,7 +70,11 @@ def read_obj(path): a = int(s[0]); vi.append(a - 1 if a > 0 else len(V) + a) if len(s) > 1 and s[1]: b = int(s[1]); ti.append(b - 1 if b > 0 else len(VT) + b) - faces.append((vi, ti if len(ti) == len(vi) else None, mat, grp)) + ti = ti if len(ti) == len(vi) else None + if not export_space: + vi = G.file_order(vi) + ti = ti and G.file_order(ti) + faces.append((vi, ti, mat, grp)) return V, C, VT, faces @@ -116,12 +123,14 @@ def main(): ap.add_argument('--zones', choices=['flat', 'keep'], default='flat', help='flat: disable zone visibility (PVS), draw everything; keep: original') ap.add_argument('--band', type=float, default=6.0, help='height band per tile (units)') + ap.add_argument('--export-space', action='store_true', + help='OBJ in raw export space (z < 0; files of geom_export --export-space / before 2026-11-07)') a = ap.parse_args() ch, wf, trak, mp = G.load_level(a.base) pages, texs = G.parse_text(G.wad_get(ch, 'TEXT')) W, H = mp['W'], mp['H'] - V, C, VT, faces = read_obj(a.obj) + V, C, VT, faces = read_obj(a.obj, a.export_space) deftex = a.default_tex if deftex is None: deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0) @@ -160,7 +169,7 @@ def main(): tris.append((P, Cc, tex, fl)) if a.coll_obj: - cV, _, _, cfaces = read_obj(a.coll_obj) + cV, _, _, cfaces = read_obj(a.coll_obj, a.export_space) polys = [] for vi, ti, mat, grp in cfaces: m = re.search(r'mat(\d+)', grp or '') diff --git a/tools/geomlib.py b/tools/geomlib.py index b895cc6..b18d05b 100644 --- a/tools/geomlib.py +++ b/tools/geomlib.py @@ -13,6 +13,24 @@ FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer). import math import struct +# ---------------------------------------------------------------- OBJ file space +# OBJ files (geom_export/geom_import/geom_demo, the editor's Import mesh and OBJ/glTF export) are +# right-handed, y up, in game space: file (x, y, z) = export (x, y, -z). The game shows export space +# left-handed, so this is how Blender shows the level the same way round as the game. Converting +# mirrors, so the corner order reverses (first corner kept: export (0,1,2) <-> file (0,2,1)). + + +def to_file(p): + """Export-space point -> OBJ file point.""" + return (p[0], p[1], -p[2]) + + +def file_order(seq): + """Corner order export <-> file (an involution).""" + seq = list(seq) + return seq[:1] + seq[:0:-1] + + # ---------------------------------------------------------------- WAD diff --git a/tools/vorschau/render_level.py b/tools/vorschau/render_level.py index e1513f5..ee5e144 100644 --- a/tools/vorschau/render_level.py +++ b/tools/vorschau/render_level.py @@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')): if ln.startswith('v '): p = ln.split() x, y, z = float(p[1]), float(p[2]), float(p[3]) - V.append((x, z, y)) # game (x, y, -z) -> Blender (x, -game_z, y) = (x, z_obj, y_obj) + V.append((x, -z, y)) # OBJ = game space (right-handed, y up) -> Blender z up C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1)) elif ln.startswith('vt '): p = ln.split(); VT.append((float(p[1]), float(p[2]))) @@ -81,7 +81,7 @@ for ln in open(os.path.join(objdir, 'level.obj')): q = t.split('/') vi.append(int(q[0]) - 1) ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1) - faces.append((vi[::-1], ti[::-1], mat)) # mirroring -> reverse winding + faces.append((vi, ti, mat)) matnames = sorted(set(f[2] for f in faces)) mesh = bpy.data.meshes.new('level')