Mesh: OBJ with Y up as Blender (and OBJ custom) expects – checked every format by importing it in Blender 5.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thunderheronightandClaude Opus 5.5 committed 2026-10-16 23:06:34 +02:00
1 parent d01571fadd
commit 6f46a2b94a
5 files changed
+14 -12

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ All notable changes to Splat Workshop. The format follows [Keep a Changelog](htt
### Added
- **Mesh export**: *Mesh …* on every scene and tour turns the splats into a surface with Spirula Studio (`spirula mesh`) – for Blender and other 3D programs, or for 3D printing. Formats: **GLB** (recommended: colours or texture in one file), **STL** (no colours, for slicers), **OBJ** (with a texture if possible, otherwise without colours) and **PLY** (colours per corner).
- **Stands upright, floor at height 0**: our scenes have Y pointing down like the Steam Frame's tracking, so a mesh used to lie on its side in Blender. Now OBJ, PLY and STL get Z up and GLB gets Y up, as the glTF standard wants (Blender turns that into Z up itself); the floor – found as for the floor plan – is moved to height 0. In metres.
- **Stands upright, floor at height 0**: our scenes have Y pointing down like the Steam Frame's tracking, so a mesh used to lie on its side in Blender. Now PLY and STL get Z up, GLB and OBJ Y up as their standard and custom want (Blender turns those into Z up itself on import – checked in Blender 5.2 for every format); the floor – found as for the floor plan – is moved to height 0. In metres.
- What lies well outside the room is left out before meshing (as the *Edges* clean-up does) – otherwise big blobs beyond the walls become lumps floating around the room.
- A **texture** from the training images when they are still there; after *clean up → training images*, colours per corner from the splats.
- The mesh lives in the scene's folder (`mesh/`); a new one replaces the old one. *Mesh ✓* shows which scenes have one.
+1 -1
View File
@@ -7,7 +7,7 @@
# <formats> comma-separated: glb, ply, obj, stl
# glb colours (texture or per corner); Y up as glTF wants it – Blender, Windows 3D viewer, web
# ply colours per corner; Z up – MeshLab, CloudCompare, Blender
# obj with a texture, or without colours; Z up – nearly every 3D program
# obj with a texture, or without colours; Y up as is custom for OBJ – nearly every 3D program
# stl no colours; Z up – slicers for 3D printing
# texture 1: a texture from the training images (only if they are still there, see "clean up"),
# otherwise colours per corner from the splats
+8 -6
View File
@@ -1,10 +1,12 @@
"""Stand a mesh upright: our scenes have Y pointing down (like the Steam Frame's tracking); 3D programs
expect Z up (OBJ, PLY, STL – e.g. Blender, slicers for 3D printing) or Y up (glTF/GLB, by its standard;
Blender turns that into Z up itself on import). Without this a room lies on its side or upside down.
"""Stand a mesh upright: our scenes have Y pointing down (like the Steam Frame's tracking). 3D programs
expect Z up for PLY and STL (Blender, slicers for 3D printing) and Y up for glTF/GLB (by its standard) and
OBJ (by custom) – Blender turns those two into Z up itself on import. Without this a room lies on its side
or upside down. (Checked by importing each format in Blender 5.2.)
Usage: python3 upright.py [--floor Y] <file> [<file> ...] (.ply .obj .stl .glb .gltf; changed in place)
OBJ, PLY, STL: positions and normals turned so that up (-Y) becomes +Z: (x, y, z) -> (x, z, -y)
PLY, STL: positions and normals turned so that up (-Y) becomes +Z: (x, y, z) -> (x, z, -y)
OBJ: turned 180° about X, up (-Y) becomes +Y: (x, y, z) -> (x, -y, -z)
GLB/glTF: the scene's root nodes go under one new node turned 180° about X: (x, y, z) -> (x, -y, -z)
– nothing else in the file is touched (textures, materials, accessors stay as they are)
--floor Y where the floor is in the scene (Y down): it is moved to height 0, so the room stands on
@@ -67,8 +69,8 @@ def obj(path: Path):
tag, rest = line.split(None, 1)
nums = rest.split()
x, y, z = (float(n) for n in nums[:3])
x, y, z = Z_UP @ (x, y, z)
z += LIFT if tag == b"v" else 0.0
x, y, z = x, -y, -z # Y up (180° about X)
y += LIFT if tag == b"v" else 0.0
line = b"%s %.7g %.7g %.7g%s\n" % (tag, x, y, z, (b" " + b" ".join(nums[3:])) if nums[3:] else b"")
out.append(line)
_replace(path, b"".join(out))
+2 -2
View File
@@ -23,12 +23,12 @@ def test_upright_puts_the_floor_at_the_bottom(tmp_path, fmt, colour):
r = subprocess.run([sys.executable, str(UPRIGHT), "--floor", str(fake_mesh.FLOOR_Y), str(f)], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
p = fake_mesh.read_points(f)
up = 1 if fmt == "glb" else 2 # glTF: Y up; the others: Z up
up = 1 if fmt in ("glb", "obj") else 2 # glTF and OBJ: Y up; PLY and STL: Z up (as Blender imports them)
k = 6 if fmt == "stl" else 4 # STL lists the corners of each triangle: 2 floor triangles
floor, ceiling = p[:k, up], p[k:, up]
assert np.allclose(floor, 0, atol=1e-5) # the floor at height 0 ...
assert np.allclose(ceiling, fake_mesh.FLOOR_Y - fake_mesh.CEILING_Y, atol=1e-5) # ... the ceiling 2.5 m above
if fmt in ("obj", "ply"): # a proper turn, not a mirror: x stays, the other two swap
if fmt in ("obj", "ply"): # a proper turn, not a mirror: x stays
assert np.allclose(p[:, 0], fake_mesh.V[:, 0])
if fmt == "ply": # normals turn along: the floor faces up
raw = f.read_bytes()
+2 -2
View File
@@ -270,7 +270,7 @@ const I18N = {
"ver.recommended": "(recommended)",
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "A mesh (surface) for 3D programs and 3D printing",
"mesh.title": "Mesh of “{name}”",
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (Z or, for GLB, Y up) with the floor at height 0; what lies well outside the room is left out. In metres.",
"mesh.hint": "Spirula Studio turns the splats into a surface – for Blender and other 3D programs or for 3D printing. It stands upright (the way Blender & co. expect each format) with the floor at height 0; what lies well outside the room is left out. In metres.",
"mesh.new": "New mesh", "mesh.have": "Mesh", "mesh.make": "Make mesh", "mesh.pick": "Pick at least one format",
"mesh.started": "Making the mesh …", "mesh.delete": "Delete the mesh",
"mesh.confirm": "Move the mesh of “{name}” to the trash?",
@@ -567,7 +567,7 @@ const I18N = {
"ver.recommended": "(empfohlen)",
"mesh.button": "Mesh …", "mesh.button.has": "Mesh ✓", "mesh.button.title": "Ein Mesh (Oberfläche) für 3D-Programme und den 3D-Druck",
"mesh.title": "Mesh von „{name}“",
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (Z oben, bei GLB Y oben) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
"mesh.hint": "Spirula Studio macht aus den Splats eine Oberfläche – für Blender und andere 3D-Programme oder für den 3D-Druck. Sie steht aufrecht (so, wie Blender & Co. jedes Format erwarten) mit dem Boden auf Höhe 0; was deutlich außerhalb des Raums liegt, bleibt weg. In Metern.",
"mesh.new": "Neues Mesh", "mesh.have": "Mesh", "mesh.make": "Mesh erstellen", "mesh.pick": "Mindestens ein Format wählen",
"mesh.started": "Erstelle das Mesh …", "mesh.delete": "Mesh löschen",
"mesh.confirm": "Das Mesh von „{name}“ in den Papierkorb verschieben?",