Files
thunderheronightandClaude Opus 5.5 24513b7483 WIP 0.5: mesh export (scripts/mesh.sh, scripts/upright.py), server job, dialog, tests
Spirula Studio meshes the splats inside the room (clean.py --outside); the result stands upright with the
floor at height 0 – Z up for OBJ/PLY/STL, Y up for GLB. Texture when the training images are still there.
Tested on real scenes (kitchen without images: colours per corner; textured on one with images) and with a
fake spirula mesh in the tests (579 passing). Not done yet: look at the dialog in the browser, changelog,
README, release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:13:17 +02:00

93 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Writers for the fake `spirula mesh` (tests/conftest.py) and readers for the tests: a tiny room as
Spirula would mesh it – in the scene's own frame, Y down: the floor at y = +1.6, the ceiling at y = -0.9,
both 4 x 4 m around x, z = 0. Each file holds two quads (four triangles)."""
import json
import struct
import numpy as np
FLOOR_Y, CEILING_Y = 1.6, -0.9
V = np.array([[x, y, z] for y in (FLOOR_Y, CEILING_Y) for x, z in ((-2, -2), (2, -2), (2, 2), (-2, 2))], np.float32)
F = np.array([[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6]], np.int32)
N = np.array([[0, -1, 0]] * 4 + [[0, 1, 0]] * 4, np.float32) # floor faces up (-Y), ceiling down
def write(path, fmt, colour):
if fmt == "ply":
props = "property float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\n"
props += "property uchar red\nproperty uchar green\nproperty uchar blue\n" if colour == "vertex" else ""
head = f"ply\nformat binary_little_endian 1.0\nelement vertex {len(V)}\n{props}element face {len(F)}\nproperty list uchar int vertex_indices\nend_header\n"
body = b""
for v, n in zip(V, N):
body += struct.pack("<6f", *v, *n) + (bytes([200, 150, 100]) if colour == "vertex" else b"")
for f in F:
body += struct.pack("<B3i", 3, *f)
path.write_bytes(head.encode() + body)
elif fmt == "obj":
lines = ["# fake"] + [f"v {x} {y} {z}" for x, y, z in V] + [f"vn {x} {y} {z}" for x, y, z in N]
lines += [f"f {a + 1} {b + 1} {c + 1}" for a, b, c in F]
path.write_text("\n".join(lines) + "\n")
elif fmt == "stl":
body = b"".join(struct.pack("<12fH", *N[f[0]], *V[f[0]], *V[f[1]], *V[f[2]], 0) for f in F)
path.write_bytes(b"\0" * 80 + struct.pack("<I", len(F)) + body)
elif fmt == "glb":
buf = V.tobytes() + F.astype(np.uint32).tobytes()
gltf = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "name": "room"}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
"buffers": [{"byteLength": len(buf)}],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": V.nbytes},
{"buffer": 0, "byteOffset": V.nbytes, "byteLength": F.size * 4}],
"accessors": [{"bufferView": 0, "componentType": 5126, "count": len(V), "type": "VEC3",
"min": V.min(0).tolist(), "max": V.max(0).tolist()},
{"bufferView": 1, "componentType": 5125, "count": F.size, "type": "SCALAR"}]}
js = json.dumps(gltf).encode()
js += b" " * (-len(js) % 4)
buf += b"\0" * (-len(buf) % 4)
path.write_bytes(struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(js) + 8 + len(buf))
+ struct.pack("<I4s", len(js), b"JSON") + js + struct.pack("<I4s", len(buf), b"BIN\0") + buf)
def read_points(path):
"""Vertex positions as a 3D program sees them (for GLB: with the node transforms applied)."""
fmt = path.suffix[1:]
raw = path.read_bytes()
if fmt == "ply":
end = raw.index(b"end_header\n") + len(b"end_header\n")
colour = b"property uchar red" in raw[:end]
dt = np.dtype([("p", "<f4", 3), ("n", "<f4", 3)] + ([("c", "u1", 3)] if colour else []))
return np.frombuffer(raw, dt, count=len(V), offset=end)["p"].astype(float)
if fmt == "obj":
return np.array([[float(x) for x in l.split()[1:4]] for l in raw.decode().splitlines() if l.startswith("v ")])
if fmt == "stl":
n, = struct.unpack_from("<I", raw, 80)
t = np.frombuffer(raw, np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("a", "<u2")]), count=n, offset=84)
return t["v"].reshape(-1, 3).astype(float)
if fmt == "glb":
jl, = struct.unpack_from("<I", raw, 12)
gltf = json.loads(raw[20:20 + jl])
bin_ = raw[20 + jl + 8:]
pts = np.frombuffer(bin_, np.float32, count=len(V) * 3).reshape(-1, 3).astype(float)
def transform(i, p):
node = gltf["nodes"][i]
if "rotation" in node:
x, y, z, w = node["rotation"]
R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
p = p @ R.T
return p + np.array(node.get("translation", [0, 0, 0]))
out = []
for root in gltf["scenes"][gltf.get("scene", 0)]["nodes"]:
def walk(i, chain):
node = gltf["nodes"][i]
if "mesh" in node:
p = pts
for j in reversed(chain + [i]):
p = transform(j, p)
out.append(p)
for c in node.get("children", []):
walk(c, chain + [i])
walk(root, [])
return np.vstack(out)