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
+515
-86
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 111 KiB |
@@ -111,6 +111,7 @@ house or a brush course:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| model is huge or tiny | wrong unit scale – use **Largest side** + **Fit** |
|
| model is huge or tiny | wrong unit scale – use **Largest side** + **Fit** |
|
||||||
| model lies on its side | switch **Up axis** |
|
| 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` |
|
| 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** |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ Ctrl inverts `enabled` while dragging.
|
|||||||
* Rotate gizmo: the edge-on fallback uses the screen angle with the mirrored sign.
|
* 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
|
* 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.
|
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,
|
* 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
|
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
|
are stored in game space (engine negates z on load). Strat yaw: `rotateY(−ry·2π/4096)` in export
|
||||||
|
|||||||
+15
-4
@@ -28,9 +28,18 @@ the spatial partition is the grid itself (cell lists) + a 32-zone visibility tab
|
|||||||
|
|
||||||
## 2. Coordinate spaces [certain]
|
## 2. Coordinate spaces [certain]
|
||||||
|
|
||||||
* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): right-handed, **y up**,
|
* **Export space** (= MAP floats "A", = vertex data of TRAK/STPC): **y up**, grid cells lie at
|
||||||
grid cells lie at x ∈ [0,W), **z ∈ (−H, 0]**. The OBJ export uses this space.
|
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).
|
* **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.
|
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π:
|
* 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`
|
`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).
|
* Whether the player start point/camera is coupled to geometry (objects.md).
|
||||||
|
|
||||||
## 10. Tools
|
## 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
|
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
|
||||||
(textured software renderer).
|
(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.
|
* `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).
|
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
+2
-2
@@ -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
|
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
|
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`:
|
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 =
|
own OBJ reader (the 3.0 importer does not know vertex colours), OBJ (= game space since
|
||||||
−game z, flip winding), material = texture (closest) × vertex colour, a little emission,
|
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
|
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
|
in row colour, animals white, exit purple ring); writes PNG + JSON per view with
|
||||||
image coordinates of the area names.
|
image coordinates of the area names.
|
||||||
|
|||||||
@@ -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
|
* 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).
|
* 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
|
* 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
|
* self-contained. Axes: files are right-handed, y up (or z up, {@code MeshAsset.upAxis}); file
|
||||||
* unit = {@code unitScale} cells. In game space z is mirrored (the result is not mirrored).
|
* (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 {
|
public final class MeshImport {
|
||||||
/** Diffuse colour of materials without one. */
|
/** Diffuse colour of materials without one. */
|
||||||
@@ -63,7 +65,8 @@ public final class MeshImport {
|
|||||||
String d0 = dir;
|
String d0 = dir;
|
||||||
files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v));
|
files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v));
|
||||||
a.setRaw("file", dir + "/" + fn);
|
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;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,8 @@ public final class MeshImport {
|
|||||||
main,
|
main,
|
||||||
rel -> s.assets().get(dir + rel.replace('\\', '/')),
|
rel -> s.assets().get(dir + rel.replace('\\', '/')),
|
||||||
a.upAxis(),
|
a.upAxis(),
|
||||||
a.unitScale());
|
a.unitScale(),
|
||||||
|
a.handedness());
|
||||||
a.setMesh(m);
|
a.setMesh(m);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
@@ -144,10 +148,21 @@ public final class MeshImport {
|
|||||||
return p[p.length - 1];
|
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)
|
public static MeshData parse(String name, byte[] main, Function<String, byte[]> files, String up, double scale)
|
||||||
throws IOException {
|
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);
|
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")) {
|
if (lower.endsWith(".obj")) {
|
||||||
obj(main, files, b);
|
obj(main, files, b);
|
||||||
} else if (lower.endsWith(".glb")) {
|
} else if (lower.endsWith(".glb")) {
|
||||||
@@ -174,26 +189,44 @@ public final class MeshImport {
|
|||||||
final List<MeshData.Material> materials = new ArrayList<>();
|
final List<MeshData.Material> materials = new ArrayList<>();
|
||||||
final String up;
|
final String up;
|
||||||
final double scale;
|
final double scale;
|
||||||
|
final boolean right;
|
||||||
|
|
||||||
Builder(String up, double scale) {
|
Builder(String up, double scale, boolean right) {
|
||||||
this.up = up;
|
this.up = up;
|
||||||
this.scale = scale;
|
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) {
|
int vertex(double x, double y, double z, Integer rgb) {
|
||||||
double gx = x;
|
double gx = x;
|
||||||
double gy = "z".equals(up) ? z : y;
|
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});
|
pos.add(new double[] {gx * scale, gy * scale, gz * scale});
|
||||||
cols.add(rgb == null ? 0xffffff : rgb);
|
cols.add(rgb == null ? 0xffffff : rgb);
|
||||||
hasCols |= rgb != null;
|
hasCols |= rgb != null;
|
||||||
return pos.size() - 1;
|
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) {
|
void tri(int a, int b, int c, double[] uv, int mat) {
|
||||||
|
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});
|
tris.add(new int[] {a, b, c});
|
||||||
uvs.add(uv);
|
uvs.add(uv);
|
||||||
|
}
|
||||||
mats.add(mat);
|
mats.add(mat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ public final class MeshAsset extends Node {
|
|||||||
String file = "";
|
String file = "";
|
||||||
/** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */
|
/** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */
|
||||||
String upAxis = "y";
|
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;
|
double unitScale = 1.0;
|
||||||
transient MeshData mesh;
|
transient MeshData mesh;
|
||||||
@@ -40,6 +46,18 @@ public final class MeshAsset extends Node {
|
|||||||
n.mesh = null;
|
n.mesh = null;
|
||||||
},
|
},
|
||||||
b -> b.choices(List.of("y", "z")))
|
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) -> {
|
.dbl("unitScale", "Unit scale (file units → cells)", n -> n.unitScale, (n, v) -> {
|
||||||
n.unitScale = v;
|
n.unitScale = v;
|
||||||
n.mesh = null;
|
n.mesh = null;
|
||||||
@@ -77,6 +95,10 @@ public final class MeshAsset extends Node {
|
|||||||
return upAxis;
|
return upAxis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String handedness() {
|
||||||
|
return handedness;
|
||||||
|
}
|
||||||
|
|
||||||
public double unitScale() {
|
public double unitScale() {
|
||||||
return unitScale;
|
return unitScale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ final class GeometryActions {
|
|||||||
try {
|
try {
|
||||||
MeshAsset a = com.eliasmueller.llamamod.editor.io.MeshImport.importFile(s, f);
|
MeshAsset a = com.eliasmueller.llamamod.editor.io.MeshImport.importFile(s, f);
|
||||||
a.setRaw("upAxis", old.upAxis());
|
a.setRaw("upAxis", old.upAxis());
|
||||||
|
a.setRaw("handedness", old.handedness());
|
||||||
a.setRaw("unitScale", old.unitScale());
|
a.setRaw("unitScale", old.unitScale());
|
||||||
a.setRaw("name", old.name());
|
a.setRaw("name", old.name());
|
||||||
com.eliasmueller.llamamod.editor.io.MeshImport.load(s, a);
|
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. */
|
/** Software preview: flat-shaded triangles, painter's algorithm, orbit by dragging. */
|
||||||
static final class Preview extends JPanel {
|
static final class Preview extends JPanel {
|
||||||
private MeshData mesh;
|
private MeshData mesh;
|
||||||
private double yaw = Math.toRadians(35);
|
/** Starts looking down at the model's front (+z, as Blender's front view shows it). */
|
||||||
private double pitch = Math.toRadians(25);
|
private double yaw = Math.toRadians(215);
|
||||||
|
|
||||||
|
private double pitch = Math.toRadians(-25);
|
||||||
private double turn;
|
private double turn;
|
||||||
private boolean mirror;
|
private boolean mirror;
|
||||||
private int lx, ly;
|
private int lx, ly;
|
||||||
@@ -309,7 +311,7 @@ final class MeshImportDialog extends JDialog {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void mouseDragged(MouseEvent e) {
|
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));
|
pitch = Math.max(-1.5, Math.min(1.5, pitch + (e.getY() - ly) * 0.01));
|
||||||
lx = e.getX();
|
lx = e.getX();
|
||||||
ly = e.getY();
|
ly = e.getY();
|
||||||
@@ -375,7 +377,8 @@ final class MeshImportDialog extends JDialog {
|
|||||||
double tx = x * ct + z * st, tz = -x * st + z * ct;
|
double tx = x * ct + z * st, tz = -x * st + z * ct;
|
||||||
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
|
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
|
||||||
double ry = y * cp - rz * sp, rd = y * sp + rz * cp;
|
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;
|
sy[v] = h2 - ry * sc;
|
||||||
sd[v] = rd;
|
sd[v] = rd;
|
||||||
cam[v] = new double[] {rx, ry, 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 tx = x * ct + z * st, tz = -x * st + z * ct;
|
||||||
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
|
double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw;
|
||||||
double ry = y * cp - rz * sp;
|
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"));
|
fc.setSelectedFile(new File((stem.isBlank() ? "level" : stem) + ".glb"));
|
||||||
JCheckBox objects = new JCheckBox("Object models", ctx.prefs().getBool("gltf.objects", false));
|
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 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();
|
JPanel boxes = new JPanel();
|
||||||
boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS));
|
boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS));
|
||||||
boxes.add(objects);
|
boxes.add(objects);
|
||||||
boxes.add(hidden);
|
boxes.add(hidden);
|
||||||
boxes.add(mirror);
|
|
||||||
JPanel acc = new JPanel(new BorderLayout(0, 6));
|
JPanel acc = new JPanel(new BorderLayout(0, 6));
|
||||||
acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
|
acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
|
||||||
acc.add(boxes, BorderLayout.NORTH);
|
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.exportDir", f.getParent().toString());
|
||||||
ctx.prefs().put("gltf.objects", objects.isSelected());
|
ctx.prefs().put("gltf.objects", objects.isSelected());
|
||||||
ctx.prefs().put("gltf.skipHidden", hidden.isSelected());
|
ctx.prefs().put("gltf.skipHidden", hidden.isSelected());
|
||||||
ctx.prefs().put("gltf.gameView", mirror.isSelected());
|
|
||||||
GltfExport.Geometry g;
|
GltfExport.Geometry g;
|
||||||
try {
|
try {
|
||||||
g = GltfExport.collect(
|
g = GltfExport.collect(
|
||||||
ctx.scene(),
|
ctx.scene(),
|
||||||
new GltfExport.Options(objects.isSelected(), mirror.isSelected()),
|
new GltfExport.Options(objects.isSelected()),
|
||||||
n -> hidden.isSelected() && ctx.isHidden(n));
|
n -> hidden.isSelected() && ctx.isHidden(n));
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
ctx.error("Export glTF", 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
|
* 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
|
* 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
|
* 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
|
* (KHR_materials_unlit, optional). Coordinates are game space (right-handed, y up, 1 unit = 1 cell;
|
||||||
* importer (y up, z = −game z, 1 unit = 1 cell); {@link Options#gameView} mirrors z instead.
|
* 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}
|
* <p>Two steps: {@link #collect} reads the scene (EDT), {@link Geometry#glb} / {@link Geometry#gltf}
|
||||||
* encode the snapshot (any thread).
|
* encode the snapshot (any thread).
|
||||||
@@ -39,12 +40,8 @@ import org.joml.Vector3f;
|
|||||||
public final class GltfExport {
|
public final class GltfExport {
|
||||||
private GltfExport() {}
|
private GltfExport() {}
|
||||||
|
|
||||||
/**
|
/** objects: also the models of placed objects (strats) as the viewport shows them. */
|
||||||
* objects: also the models of placed objects (strats) as the viewport shows them; gameView:
|
public record Options(boolean objects) {}
|
||||||
* 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) {}
|
|
||||||
|
|
||||||
/** One glTF primitive: unique vertices + triangle indices. */
|
/** One glTF primitive: unique vertices + triangle indices. */
|
||||||
static final class Prim {
|
static final class Prim {
|
||||||
@@ -383,10 +380,8 @@ public final class GltfExport {
|
|||||||
ri.transform.transformPosition(
|
ri.transform.transformPosition(
|
||||||
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]));
|
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]));
|
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;
|
p.z = -p.z;
|
||||||
n.z = -n.z;
|
n.z = -n.z;
|
||||||
}
|
|
||||||
int c = ri.vertexColors != null
|
int c = ri.vertexColors != null
|
||||||
&& (fl & RenderMesh.NO_VERTEX_COLOR) == 0
|
&& (fl & RenderMesh.NO_VERTEX_COLOR) == 0
|
||||||
&& v < ri.vertexColors.length
|
&& 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]};
|
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;
|
rgba[k] = c;
|
||||||
}
|
}
|
||||||
if (o.gameView()) {
|
|
||||||
float[] x = tri[1];
|
float[] x = tri[1];
|
||||||
tri[1] = tri[2];
|
tri[1] = tri[2];
|
||||||
tri[2] = x;
|
tri[2] = x;
|
||||||
int y = rgba[1];
|
int y = rgba[1];
|
||||||
rgba[1] = rgba[2];
|
rgba[1] = rgba[2];
|
||||||
rgba[2] = y;
|
rgba[2] = y;
|
||||||
}
|
|
||||||
// unit normals (glTF requires them): face normal where the vertex normal is zero
|
// 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]);
|
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]);
|
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.obj"));
|
||||||
assertTrue(s.assets().containsKey("assets/box/box.mtl"));
|
assertTrue(s.assets().containsKey("assets/box/box.mtl"));
|
||||||
assertTrue(s.assets().containsKey("assets/box/check.png"));
|
assertTrue(s.assets().containsKey("assets/box/check.png"));
|
||||||
// game z = −file z
|
// game = file coordinates (right-handed, y up); first vertex used: 8 = (−1, 1, 1)
|
||||||
assertEquals(-1.0, m.positions()[2], 1e-9);
|
assertEquals(1.0, m.positions()[2], 1e-9);
|
||||||
// reload from the assets gives the same mesh
|
// reload from the assets gives the same mesh
|
||||||
MeshData again = MeshImport.load(s, obj);
|
MeshData again = MeshImport.load(s, obj);
|
||||||
assertArrayEquals(m.positions(), again.positions());
|
assertArrayEquals(m.positions(), again.positions());
|
||||||
@@ -140,7 +140,7 @@ class MeshImportTest {
|
|||||||
MeshData g = MeshImport.importFile(s, dir.resolve("tri.gltf")).mesh();
|
MeshData g = MeshImport.importFile(s, dir.resolve("tri.gltf")).mesh();
|
||||||
assertEquals(1, g.triangleCount());
|
assertEquals(1, g.triangleCount());
|
||||||
assertEquals(0.5, g.positions()[1], 1e-6, "translation applied");
|
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());
|
assertTrue(g.materialList().getFirst().doubleSided());
|
||||||
Files.write(dir.resolve("tri.glb"), glb(gltfJson()));
|
Files.write(dir.resolve("tri.glb"), glb(gltfJson()));
|
||||||
MeshData gb = MeshImport.importFile(s, dir.resolve("tri.glb")).mesh();
|
MeshData gb = MeshImport.importFile(s, dir.resolve("tri.glb")).mesh();
|
||||||
|
|||||||
+80
@@ -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 {
|
void villageExportsAsValidGlbAndGltf() throws Exception {
|
||||||
Project pr = Project.create(cd(), "edit", "t1l1m001");
|
Project pr = Project.create(cd(), "edit", "t1l1m001");
|
||||||
Scene s = pr.scene();
|
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());
|
assertTrue(g.triangles() > 20000, "village triangles: " + g.triangles());
|
||||||
byte[] glb = g.glb();
|
byte[] glb = g.glb();
|
||||||
Parsed p = parseGlb(glb);
|
Parsed p = parseGlb(glb);
|
||||||
assertEquals(g.triangles(), validate(p));
|
assertEquals(g.triangles(), validate(p));
|
||||||
assertTrue(list(p.json(), "images").size() > 5, "texture pages");
|
assertTrue(list(p.json(), "images").size() > 5, "texture pages");
|
||||||
assertTrue(list(p.json(), "materials").size() > 3);
|
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[] lo = {1e9, 1e9, 1e9};
|
||||||
double[] hi = {-1e9, -1e9, -1e9};
|
double[] hi = {-1e9, -1e9, -1e9};
|
||||||
for (Object a : list(p.json(), "accessors")) {
|
for (Object a : list(p.json(), "accessors")) {
|
||||||
@@ -227,7 +227,7 @@ class GltfExportTest {
|
|||||||
double tx1 = drawn.stream().mapToDouble(Tile::x).max().orElseThrow();
|
double tx1 = drawn.stream().mapToDouble(Tile::x).max().orElseThrow();
|
||||||
double tz0 = drawn.stream().mapToDouble(Tile::z).min().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[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
|
// .gltf: same content, buffer as data URI
|
||||||
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(g.gltf(), StandardCharsets.UTF_8));
|
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));
|
byte[] bin = Base64.getDecoder().decode(uri.substring(uri.indexOf(',') + 1));
|
||||||
assertEquals(g.triangles(), validate(new Parsed(j, bin)));
|
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);
|
MeshData md = MeshImport.parse("v.glb", glb, f -> null, "y", 1);
|
||||||
assertEquals(g.triangles(), md.triangleCount());
|
assertEquals(g.triangles(), md.triangleCount());
|
||||||
double[] b = md.bounds();
|
double[] b = md.bounds();
|
||||||
assertEquals(lo[0], b[0], 1e-3);
|
assertEquals(lo[0], b[0], 1e-3);
|
||||||
assertEquals(hi[0], b[3], 1e-3);
|
assertEquals(hi[0], b[3], 1e-3);
|
||||||
assertEquals(-hi[2], b[2], 1e-3);
|
assertEquals(lo[2], b[2], 1e-3);
|
||||||
assertEquals(-lo[2], b[5], 1e-3);
|
assertEquals(hi[2], b[5], 1e-3);
|
||||||
assertTrue(md.materialList().stream().filter(m -> m.image() != null).count() > 5, "textures read back");
|
assertTrue(md.materialList().stream().filter(m -> m.image() != null).count() > 5, "textures read back");
|
||||||
assertNotNull(md.colors(), "vertex colours read back");
|
assertNotNull(md.colors(), "vertex colours read back");
|
||||||
|
|
||||||
// game orientation: z mirrored, same triangles
|
GltfExport.Geometry o = GltfExport.collect(s, new GltfExport.Options(true), n -> false);
|
||||||
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);
|
|
||||||
assertTrue(o.triangles() > g.triangles(), "objects add triangles");
|
assertTrue(o.triangles() > g.triangles(), "objects add triangles");
|
||||||
assertEquals(o.triangles(), validate(parseGlb(o.glb())));
|
assertEquals(o.triangles(), validate(parseGlb(o.glb())));
|
||||||
}
|
}
|
||||||
@@ -264,7 +257,7 @@ class GltfExportTest {
|
|||||||
Scene s = Project.create(cd(), "new", "t1l1m001").scene();
|
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))
|
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);
|
.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());
|
Parsed p = parseGlb(g.glb());
|
||||||
assertEquals(g.triangles(), validate(p));
|
assertEquals(g.triangles(), validate(p));
|
||||||
assertTrue(g.triangles() >= 10, "box faces: " + g.triangles());
|
assertTrue(g.triangles() >= 10, "box faces: " + g.triangles());
|
||||||
@@ -273,23 +266,26 @@ class GltfExportTest {
|
|||||||
assertEquals(10, b[0], 1e-3);
|
assertEquals(10, b[0], 1e-3);
|
||||||
assertEquals(12, b[3], 1e-3);
|
assertEquals(12, b[3], 1e-3);
|
||||||
assertEquals(1, b[4], 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
|
// 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());
|
assertEquals(0, none.triangles());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void excludedTilesAreLeftOut() throws Exception {
|
void excludedTilesAreLeftOut() throws Exception {
|
||||||
Scene s = Project.create(cd(), "edit", "t1l1m001").scene();
|
Scene s = Project.create(cd(), "edit", "t1l1m001").scene();
|
||||||
int all = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
|
int all =
|
||||||
.triangles();
|
GltfExport.collect(s, new GltfExport.Options(false), n -> false).triangles();
|
||||||
Tile t = s.tiles().stream()
|
Tile t = s.tiles().stream()
|
||||||
.filter(x -> x.model() != null && x.model().faceCount() > 4)
|
.filter(x -> x.model() != null && x.model().faceCount() > 4)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow();
|
.orElseThrow();
|
||||||
t.prop("excluded").setRaw(t, true);
|
t.prop("excluded").setRaw(t, true);
|
||||||
int less = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
|
int less =
|
||||||
.triangles();
|
GltfExport.collect(s, new GltfExport.Options(false), n -> false).triangles();
|
||||||
assertTrue(less < all && less >= all - t.model().faceCount(), all + " → " + less);
|
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]));
|
Path out = Files.createDirectories(Path.of(a[1]));
|
||||||
for (int i = 2; i < a.length; i++) {
|
for (int i = 2; i < a.length; i++) {
|
||||||
Scene s = Project.create(cd, "edit", a[i]).scene();
|
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] + ".glb"), g.glb());
|
||||||
Files.write(out.resolve(a[i] + ".gltf"), g.gltf());
|
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, "
|
System.out.println(a[i] + ": " + g.triangles() + " triangles, " + g.vertices() + " vertices, "
|
||||||
+ g.primitives() + " primitives");
|
+ g.primitives() + " primitives");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -33,7 +33,7 @@ class ObjW:
|
|||||||
self.f.append((tex, mat, [base + k for k in range(4)]))
|
self.f.append((tex, mat, [base + k for k in range(4)]))
|
||||||
|
|
||||||
def box(self, x0, z0, x1, z1, y0, y1, top, side):
|
def box(self, x0, z0, x1, z1, y0, y1, top, side):
|
||||||
# coordinates: x0<x1, z0>z1 (negative z = further into the grid)
|
# export space: x0<x1, z0>z1 (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)
|
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))]:
|
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
|
# wall from a to b, outside = clockwise
|
||||||
@@ -45,12 +45,12 @@ class ObjW:
|
|||||||
def save(self, path):
|
def save(self, path):
|
||||||
with open(path, 'w') as fo:
|
with open(path, 'w') as fo:
|
||||||
for p in self.v:
|
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:
|
for u, v in self.vt:
|
||||||
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
|
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
|
||||||
for tex, mat, ids in self.f:
|
for tex, mat, ids in self.f:
|
||||||
fo.write(f'g demo_mat{mat:02d}\nusemtl tex_{tex}\n')
|
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():
|
def main():
|
||||||
|
|||||||
+18
-11
@@ -11,7 +11,9 @@ Writes to outdir:
|
|||||||
objects.obj (--objects) static STPC object models side by side
|
objects.obj (--objects) static STPC object models side by side
|
||||||
preview_top.png, preview_persp.png textured software renderings for checking
|
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
|
Format: notes/geometry.md
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -61,7 +63,7 @@ def flat_color(t):
|
|||||||
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
|
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:
|
with open(path, 'w') as fo:
|
||||||
fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n')
|
fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n')
|
||||||
fo.write(f'mtllib {mtlname}\n')
|
fo.write(f'mtllib {mtlname}\n')
|
||||||
@@ -77,10 +79,11 @@ def write_obj(path, tris, texs, mtlname):
|
|||||||
if mat != cur_mat:
|
if mat != cur_mat:
|
||||||
fo.write(f'usemtl {mat}\n')
|
fo.write(f'usemtl {mat}\n')
|
||||||
cur_mat = mat
|
cur_mat = mat
|
||||||
for p, c in zip(P, C):
|
order = [0, 1, 2] if raw else G.file_order(range(3))
|
||||||
fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*p, *c))
|
for k in order:
|
||||||
for u, v in uv:
|
fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*(P[k] if raw else G.to_file(P[k])), *C[k]))
|
||||||
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
|
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')
|
fo.write(f'f {vi}/{vi} {vi + 1}/{vi + 1} {vi + 2}/{vi + 2}\n')
|
||||||
vi += 3
|
vi += 3
|
||||||
|
|
||||||
@@ -168,12 +171,16 @@ def main():
|
|||||||
ap.add_argument('--objects', action='store_true')
|
ap.add_argument('--objects', action='store_true')
|
||||||
ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews')
|
ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews')
|
||||||
ap.add_argument('--size', default='1200x900')
|
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()
|
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)
|
os.makedirs(a.outdir, exist_ok=True)
|
||||||
ch, wf, trak, mp = G.load_level(a.wad)
|
ch, wf, trak, mp = G.load_level(a.wad)
|
||||||
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
|
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
|
||||||
tris = world_tris(trak, mp, texs)
|
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))
|
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"]} '
|
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)}')
|
f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}')
|
||||||
@@ -194,8 +201,8 @@ def main():
|
|||||||
if nrm @ np.array(c['plane'][:3]) < 0:
|
if nrm @ np.array(c['plane'][:3]) < 0:
|
||||||
pts = pts[::-1]
|
pts = pts[::-1]
|
||||||
fo.write(f'g mat{c["mat"]:02d}_grp{g}\n')
|
fo.write(f'g mat{c["mat"]:02d}_grp{g}\n')
|
||||||
for p in pts:
|
for p in order(pts):
|
||||||
fo.write('v %.5f %.5f %.5f\n' % f(*p))
|
fo.write('v %.5f %.5f %.5f\n' % out(f(*p)))
|
||||||
fo.write('f ' + ' '.join(str(vi + k) for k in range(len(pts))) + '\n')
|
fo.write('f ' + ' '.join(str(vi + k) for k in range(len(pts))) + '\n')
|
||||||
vi += len(pts)
|
vi += len(pts)
|
||||||
|
|
||||||
@@ -211,7 +218,7 @@ def main():
|
|||||||
dx = ox - min(xs)
|
dx = ox - min(xs)
|
||||||
fo.write(f'o stpc_model_{k:03d}\n')
|
fo.write(f'o stpc_model_{k:03d}\n')
|
||||||
for v in m.verts:
|
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:
|
for fc in m.faces:
|
||||||
t = texs[fc['tex']] if fc['tex'] < len(texs) else None
|
t = texs[fc['tex']] if fc['tex'] < len(texs) else None
|
||||||
if t and not (t['flags'] & 1) and t['page'] >= 0:
|
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')
|
uv = [(0, 0)] * 3; fo.write(f'usemtl flat_{fc["tex"]}\n')
|
||||||
for u, v in uv:
|
for u, v in uv:
|
||||||
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
|
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
|
vt += 3
|
||||||
vi += len(m.verts)
|
vi += len(m.verts)
|
||||||
ox += max(xs) - min(xs) + 0.5
|
ox += max(xs) - min(xs) + 0.5
|
||||||
|
|||||||
+16
-7
@@ -4,8 +4,9 @@
|
|||||||
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
|
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
|
||||||
[--keep-tiles] [--band 6]
|
[--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
|
Input OBJ in game space as geom_export.py writes it (right-handed, y up, 1 unit = 1 tile, z > 0
|
||||||
grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1).
|
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
|
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.
|
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]
|
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 = [], [], []
|
V, C, VT = [], [], []
|
||||||
faces = [] # (idx list, vt list or None, material, group)
|
faces = [] # (idx list, vt list or None, material, group)
|
||||||
mat, grp = None, ''
|
mat, grp = None, ''
|
||||||
@@ -52,7 +54,8 @@ def read_obj(path):
|
|||||||
if not p:
|
if not p:
|
||||||
continue
|
continue
|
||||||
if p[0] == 'v':
|
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)
|
C.append(tuple(float(x) for x in p[4:7]) if len(p) >= 7 else None)
|
||||||
elif p[0] == 'vt':
|
elif p[0] == 'vt':
|
||||||
VT.append((float(p[1]), float(p[2])))
|
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)
|
a = int(s[0]); vi.append(a - 1 if a > 0 else len(V) + a)
|
||||||
if len(s) > 1 and s[1]:
|
if len(s) > 1 and s[1]:
|
||||||
b = int(s[1]); ti.append(b - 1 if b > 0 else len(VT) + b)
|
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
|
return V, C, VT, faces
|
||||||
|
|
||||||
|
|
||||||
@@ -116,12 +123,14 @@ def main():
|
|||||||
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
|
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
|
||||||
help='flat: disable zone visibility (PVS), draw everything; keep: original')
|
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('--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()
|
a = ap.parse_args()
|
||||||
|
|
||||||
ch, wf, trak, mp = G.load_level(a.base)
|
ch, wf, trak, mp = G.load_level(a.base)
|
||||||
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
|
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
|
||||||
W, H = mp['W'], mp['H']
|
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
|
deftex = a.default_tex
|
||||||
if deftex is None:
|
if deftex is None:
|
||||||
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
|
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))
|
tris.append((P, Cc, tex, fl))
|
||||||
|
|
||||||
if a.coll_obj:
|
if a.coll_obj:
|
||||||
cV, _, _, cfaces = read_obj(a.coll_obj)
|
cV, _, _, cfaces = read_obj(a.coll_obj, a.export_space)
|
||||||
polys = []
|
polys = []
|
||||||
for vi, ti, mat, grp in cfaces:
|
for vi, ti, mat, grp in cfaces:
|
||||||
m = re.search(r'mat(\d+)', grp or '')
|
m = re.search(r'mat(\d+)', grp or '')
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer).
|
|||||||
import math
|
import math
|
||||||
import struct
|
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
|
# ---------------------------------------------------------------- WAD
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
|
|||||||
if ln.startswith('v '):
|
if ln.startswith('v '):
|
||||||
p = ln.split()
|
p = ln.split()
|
||||||
x, y, z = float(p[1]), float(p[2]), float(p[3])
|
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))
|
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
|
||||||
elif ln.startswith('vt '):
|
elif ln.startswith('vt '):
|
||||||
p = ln.split(); VT.append((float(p[1]), float(p[2])))
|
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('/')
|
q = t.split('/')
|
||||||
vi.append(int(q[0]) - 1)
|
vi.append(int(q[0]) - 1)
|
||||||
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -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))
|
matnames = sorted(set(f[2] for f in faces))
|
||||||
mesh = bpy.data.meshes.new('level')
|
mesh = bpy.data.meshes.new('level')
|
||||||
|
|||||||
Reference in new issue
Block a user