From 1b8b29f73f9844b4e25181ece3ebf39669925118 Mon Sep 17 00:00:00 2001 From: 6wheel Date: Sat, 11 Jul 2026 13:29:30 -0300 Subject: [PATCH] Add .3ds (Autodesk 3D Studio) file format support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trimesh 4.x has no built-in .3ds loader, so this adds a from-scratch chunk parser (tds_reader.py) and a 3DS->OBJ converter (tds_to_obj.py), mirroring the existing .kn5 path: convert to a temporary OBJ, then reuse _load_obj_with_real_groups so real per-object names become UI parts and UVs feed the AO bake. No new dependencies. - 3DS is Z-up; the renderer is Y-up. tds_to_obj bakes a Z-up -> Y-up rotation (x,y,z)->(x,z,-y) (-90deg about X, a proper rotation so face winding is preserved), scoped ONLY to the 3DS path — no other format or the global AxisConfig is touched. - Corrupt/truncated input is validated before writing: non-finite vertex coordinates and out-of-range face indices raise a clean ModelLoadError instead of a NaN-blank render or a raw IndexError. - Duplicate 3DS object names are disambiguated safely (probed against all emitted names) so parts don't silently merge. - Registered .3ds in SUPPORTED_EXTENSIONS (also enables .3ds inside zips), the upload accept filter, and the docs. - test_3ds.py: happy-path (hand-encoded 3DS) + corrupt-input/duplicate -name robustness. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + README.md | 3 +- model_loader.py | 20 ++++- tds_reader.py | 189 +++++++++++++++++++++++++++++++++++++++++++ tds_to_obj.py | 119 +++++++++++++++++++++++++++ templates/index.html | 4 +- test_3ds.py | 178 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 tds_reader.py create mode 100644 tds_to_obj.py create mode 100644 test_3ds.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index e9970a4..76f3d5d 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ guess at. `.obj`, `.dae` (Collada — common for BeamNG mods), `.stl`, `.gltf` / `.glb`, `.kn5` (Assetto Corsa — converted internally by our own from-scratch -parser). +parser), `.3ds` (Autodesk 3D Studio — also converted internally by our own +from-scratch parser). ## If something goes wrong diff --git a/model_loader.py b/model_loader.py index aa192de..7f6687e 100644 --- a/model_loader.py +++ b/model_loader.py @@ -9,6 +9,9 @@ .gltf / .glb - direct trimesh load .kn5 - converted via our own from-scratch parser (kn5_reader.py / kn5_to_obj.py) to a temporary OBJ, then loaded normally + .3ds - converted via our own from-scratch parser (tds_reader.py + / tds_to_obj.py) to a temporary OBJ, then loaded normally + (trimesh has no built-in 3DS loader) Every path strips texture visuals before returning, because trimesh auto-generates a TextureVisuals object whenever UV coordinates are present @@ -23,8 +26,9 @@ import trimesh from kn5_to_obj import convert as kn5_convert +from tds_to_obj import convert as tds_convert -SUPPORTED_EXTENSIONS = {".obj", ".dae", ".stl", ".gltf", ".glb", ".kn5"} +SUPPORTED_EXTENSIONS = {".obj", ".dae", ".stl", ".gltf", ".glb", ".kn5", ".3ds"} class ModelLoadError(Exception): @@ -123,8 +127,9 @@ def load_model(path): against mesh.faces, so the caller can map checkbox selections back to actual faces regardless of input format. uv is an (n_vertices, 2) array aligned with mesh.vertices, or None if no usable UV data was found — - only the KN5/OBJ path currently extracts it (see _load_obj_with_real_groups); - other formats (.dae/.stl/.gltf/.glb) always return None here for now. + only the KN5/OBJ/3DS paths currently extract it (they share + _load_obj_with_real_groups); other formats (.dae/.stl/.gltf/.glb) always + return None here for now. """ ext = os.path.splitext(path)[1].lower() if ext not in SUPPORTED_EXTENSIONS: @@ -142,6 +147,15 @@ def load_model(path): raise ModelLoadError(f"Failed to convert KN5 file: {e}") from e return _load_obj_with_real_groups(tmp_obj) + if ext == ".3ds": + with tempfile.TemporaryDirectory() as tmpdir: + tmp_obj = os.path.join(tmpdir, "converted.obj") + try: + tds_convert(path, tmp_obj) + except Exception as e: + raise ModelLoadError(f"Failed to convert 3DS file: {e}") from e + return _load_obj_with_real_groups(tmp_obj) + if ext == ".obj": return _load_obj_with_real_groups(path) diff --git a/tds_reader.py b/tds_reader.py new file mode 100644 index 0000000..a399f3d --- /dev/null +++ b/tds_reader.py @@ -0,0 +1,189 @@ +""" +tds_reader.py — From-scratch parser for the Autodesk 3D Studio `.3ds` binary +format, extracting each named mesh object's vertices, triangle faces and +(optional) UV coordinates. + +Mirrors the approach used for the KN5 format (kn5_reader.py): we parse the +format ourselves rather than pulling in a heavy native dependency (assimp), +keeping the project's dependency list lean. trimesh 4.x has no built-in `.3ds` +loader (confirmed: '3ds' is absent from trimesh.available_formats()), so this +is required for 3DS support. + +The 3DS format is a tree of length-prefixed chunks. Each chunk header is: + uint16 chunk id + uint32 chunk length (INCLUDES the 6-byte header) +all little-endian. We only interpret the handful of chunks needed to rebuild +geometry; everything else (materials, lights, cameras, keyframer data, face +smoothing/material sub-groups) is skipped by honoring the declared length. + +Chunk ids we care about: + 0x4D4D MAIN + 0x3D3D EDIT3DS (mesh editor block) + 0x4000 OBJECT_BLOCK -> null-terminated name, then sub-chunks + 0x4100 TRIMESH + 0x4110 VERTEX_LIST uint16 n, then n * 3 float32 + 0x4120 FACE_LIST uint16 n, then n * 4 uint16 (a,b,c,flags) + 0x4140 TEX_COORDS uint16 n, then n * 2 float32 + 0x4160 MESH_MATRIX local coordinate system (see note below) + +Note on coordinates: like most simple 3DS loaders, we use the vertex list as +stored and do NOT apply MESH_MATRIX (0x4160). In practice 3DS files store +vertices already baked into world space, and the mesh matrix describes the +object's local pivot axes; applying it would double-transform. 3DS is a +Z-up, right-handed format — this reader preserves coordinates faithfully (the +exact bytes in the file), and the Z-up -> Y-up conversion the renderer needs +is done one level up in tds_to_obj.py (scoped to the 3DS path only), the same +way kn5_to_obj.py owns KN5's coordinate handling. +""" + +import struct +import sys + +# Chunk identifiers (little-endian uint16). +_MAIN = 0x4D4D +_EDIT3DS = 0x3D3D +_OBJECT_BLOCK = 0x4000 +_TRIMESH = 0x4100 +_VERTEX_LIST = 0x4110 +_FACE_LIST = 0x4120 +_TEX_COORDS = 0x4140 + +# Chunks whose only job is to hold more sub-chunks: we recurse into them. +_CONTAINER_CHUNKS = (_MAIN, _EDIT3DS, _TRIMESH) + + +class TdsMesh: + """One named mesh object parsed out of a 3DS file. + + vertices : list of (x, y, z) float tuples + faces : list of (a, b, c) int index tuples (0-based into vertices) + uvs : list of (u, v) float tuples, parallel to vertices, or [] if the + object carried no texture coordinates + """ + + def __init__(self, name): + self.name = name + self.vertices = [] + self.faces = [] + self.uvs = [] + + +class TdsParseError(Exception): + pass + + +def _read_cstr(data, start, end): + """Reads a NUL-terminated ASCII string beginning at `start`, not crossing + `end`. Returns (text, index_just_past_the_NUL).""" + p = start + while p < end and data[p] != 0: + p += 1 + text = data[start:p].decode("latin-1", errors="replace") + return text, min(p + 1, end) # +1 to step over the NUL terminator + + +def _walk(data, start, end, state): + """Iterates sibling chunks in the byte range [start, end), dispatching the + ones we understand and skipping the rest by their declared length.""" + p = start + while p + 6 <= end: + cid, clen = struct.unpack_from(" end: + cend = end + + if cid in _CONTAINER_CHUNKS: + _walk(data, cstart, cend, state) + + elif cid == _OBJECT_BLOCK: + name, after_name = _read_cstr(data, cstart, cend) + mesh = TdsMesh(name) + state["current"] = mesh + _walk(data, after_name, cend, state) + # An OBJECT_BLOCK can also describe a light or camera (no TRIMESH + # sub-chunk), which leaves this empty — only keep real geometry. + if mesh.vertices and mesh.faces: + state["meshes"].append(mesh) + state["current"] = None + + elif cid == _VERTEX_LIST: + cur = state["current"] + if cur is not None and cstart + 2 <= cend: + n = struct.unpack_from(" cend: + break + x, y, z = struct.unpack_from(" cend: + break + a, b, c, _flags = struct.unpack_from(" cend: + break + u, v = struct.unpack_from("= 6 else len(data) + state = {"meshes": [], "current": None} + _walk(data, 6, end, state) + return state["meshes"] + + +if __name__ == "__main__": + for m in read_tds(sys.argv[1]): + print(f"{m.name}: {len(m.vertices)} verts, {len(m.faces)} faces, " + f"{len(m.uvs)} uvs") diff --git a/tds_to_obj.py b/tds_to_obj.py new file mode 100644 index 0000000..40f80b9 --- /dev/null +++ b/tds_to_obj.py @@ -0,0 +1,119 @@ +""" +tds_to_obj.py — Converts a parsed 3DS scene (from tds_reader.py) into a single +OBJ file, with one named group per 3DS mesh object. + +This mirrors kn5_to_obj.py so that both formats funnel through the exact same, +already-tested OBJ path in model_loader._load_obj_with_real_groups(), which +preserves real per-part group names (for the UI's per-part checkboxes) and +extracts UVs for the AO bake. + +Coordinate system: 3DS is a Z-up format, but the renderer (and every other +loader in this project) works in the Y-up convention its default AxisConfig +assumes. So here — and ONLY on this 3DS path, leaving all other formats +untouched — we bake a Z-up -> Y-up conversion into the written vertices: + + (x, y, z) -> (x, z, -y) # rotation of -90 degrees about the X axis + +This is a proper rotation (determinant +1, so triangle winding is preserved) +and it maps the model's up axis (+Z) onto the renderer's up axis (+Y) and the +model's forward axis (+Y) onto -Z (the 'front' camera side). Without it, the +orthographic views come out rotated (front<->bottom, top<->front, and the +left/right views rolled 90 degrees) — the exact symptom real 3DS files show. +Tex coords are unaffected (a spatial rotation doesn't touch UV space). +""" + +import math +import sys + +import tds_reader + + +def convert(tds_path, obj_path): + meshes = tds_reader.read_tds(tds_path) + meshes = [m for m in meshes if m.vertices and m.faces] + if not meshes: + raise ValueError("3DS file contains no mesh geometry.") + + # Validate geometry BEFORE writing anything. A corrupt or truncated 3DS + # can yield non-finite vertex coordinates (a NaN/Inf float32 would sail + # through OBJ as a literal 'nan'/'inf' token that float() happily parses, + # poisoning mesh.bounds and blanking every render) or face indices past + # the object's own vertex count (which would raise a raw IndexError deep + # inside _load_obj_with_real_groups, outside the caller's try/except). + # Catch both here and raise ValueError, which load_model turns into a + # clean ModelLoadError instead of a stack trace. + for m in meshes: + n_verts = len(m.vertices) + for (x, y, z) in m.vertices: + if not (math.isfinite(x) and math.isfinite(y) and math.isfinite(z)): + raise ValueError( + f"3DS object '{m.name}' has a non-finite vertex coordinate " + f"(NaN or infinity) — the file is corrupt." + ) + for (a, b, c) in m.faces: + if not (0 <= a < n_verts and 0 <= b < n_verts and 0 <= c < n_verts): + raise ValueError( + f"3DS object '{m.name}' has a face referencing a vertex " + f"that doesn't exist (index out of range) — the file is " + f"corrupt or truncated." + ) + + # UVs are only usable for the AO bake if EVERY object has one UV per + # vertex — OBJ 'vt' indices are global across the file, so a single + # object missing UVs would desync every following object's vt index from + # its v index. UVs are optional for line-art rendering, so if the set is + # inconsistent we drop them entirely rather than emit a corrupt mapping. + global_has_uv = all(len(m.uvs) == len(m.vertices) for m in meshes) + + # 3DS object names are short and can repeat; keep parts distinct in the UI + # by suffixing duplicates (_1, _2, ...), same spirit as _load_with_parts. + # We probe against every name already emitted (not just a per-base + # counter), so a suffixed name can't collide with a real object that + # happens to be named like the suffix — e.g. objects ["A", "A", "A_1"] + # would otherwise emit two groups both named "A_1" and silently merge + # their faces into one part. + used_names = set() + + def unique_name(raw): + base = raw.strip() or "object" + name = base + suffix = 0 + while name in used_names: + suffix += 1 + name = f"{base}_{suffix}" + used_names.add(name) + return name + + with open(obj_path, "w") as out: + vertex_offset = 0 + for m in meshes: + out.write(f"g {unique_name(m.name)}\n") + + for (x, y, z) in m.vertices: + # Z-up (3DS) -> Y-up (renderer): rotate -90 deg about X. + out.write(f"v {x} {z} {-y}\n") + if global_has_uv: + for (u, v) in m.uvs: + out.write(f"vt {u} {v}\n") + + for (a, b, c) in m.faces: + # OBJ indices are 1-based and global across the whole file. We + # write one UV per vertex, so the global vt index equals the + # global v index for every corner (valid because every object + # contributes equal v and vt counts when global_has_uv). + ia = vertex_offset + a + 1 + ib = vertex_offset + b + 1 + ic = vertex_offset + c + 1 + if global_has_uv: + out.write(f"f {ia}/{ia} {ib}/{ib} {ic}/{ic}\n") + else: + out.write(f"f {ia} {ib} {ic}\n") + + vertex_offset += len(m.vertices) + + print(f"Wrote {obj_path}: {len(meshes)} object(s)" + f"{'' if global_has_uv else ' (no UVs)'}") + + +if __name__ == "__main__": + convert(sys.argv[1], sys.argv[2]) diff --git a/templates/index.html b/templates/index.html index 3adc216..92ada41 100644 --- a/templates/index.html +++ b/templates/index.html @@ -117,9 +117,9 @@

Orthographic Template Generator

1. Model File

Drop a model file or a zipped mod folder here, or click to choose one
- (.obj, .dae, .stl, .gltf, .glb, .kn5, or a .zip containing one of those) + (.obj, .dae, .stl, .gltf, .glb, .kn5, .3ds, or a .zip containing one of those)
- + diff --git a/test_3ds.py b/test_3ds.py new file mode 100644 index 0000000..b61603d --- /dev/null +++ b/test_3ds.py @@ -0,0 +1,178 @@ +""" +test_3ds.py — Self-contained smoke test for the .3ds loader path. + +Builds a tiny 3DS file in memory (two named unit-cube objects, each with UVs), +hand-encoded straight from the 3DS chunk spec so it's an independent oracle for +tds_reader.py (the test writer shares no code with the reader), then verifies: + + * tds_reader.read_tds() parses both objects with correct vertex/face counts + * model_loader.load_model() returns a merged mesh, per-part face ranges keyed + by the real object names, and a per-vertex UV array + +Run: python test_3ds.py (exits non-zero on failure) +""" + +import os +import struct +import tempfile + +import tds_reader +from model_loader import load_model + + +# --- Minimal 3DS chunk encoder (spec-faithful, independent of the reader) --- + +def _chunk(cid, payload): + return struct.pack(" Y-up rotation + # (x, y, z) -> (x, z, -y) was applied on the 3DS path. Source cubes + # span x,y,z in [0,1] (BoxB offset +2 in x); after the rotation the + # merged bounds are x:[0,3], y:[0,1], z:[-1,0]. + lo = mesh.vertices.min(axis=0) + hi = mesh.vertices.max(axis=0) + assert abs(lo[0]) < 1e-5 and abs(hi[0] - 3.0) < 1e-5, (lo, hi) # x unchanged + assert abs(lo[1]) < 1e-5 and abs(hi[1] - 1.0) < 1e-5, (lo, hi) # y' = z in [0,1] + assert abs(lo[2] + 1.0) < 1e-5 and abs(hi[2]) < 1e-5, (lo, hi) # z' = -y in [-1,0] + + print("test_3ds: OK — 3DS parse + load_model path verified.") + + +def _mesh_object(name, verts, faces): + """Builds a bare OBJECT_BLOCK with an arbitrary vertex/face set (no UVs), + for the corrupt-input robustness cases below.""" + vlist = struct.pack(" ModelLoadError, not IndexError. + p = _write_temp(tmp, _scene([_mesh_object("Bad", good, [(0, 1, 9)])])) + try: + load_model(p) + raise AssertionError("#1: expected ModelLoadError for OOB face index") + except ModelLoadError: + pass + + # #3 non-finite vertex -> ModelLoadError, not a NaN-poisoned mesh. + nan_verts = [(0, 0, 0), (1, 0, 0), (float("nan"), 1, 0)] + p = _write_temp(tmp, _scene([_mesh_object("Nan", nan_verts, tri)])) + try: + load_model(p) + raise AssertionError("#3: expected ModelLoadError for NaN vertex") + except ModelLoadError: + pass + + # #2 names ['A','A','A_1'] must yield three distinct parts. + p = _write_temp(tmp, _scene([ + _mesh_object("A", good, tri), + _mesh_object("A", good, tri), + _mesh_object("A_1", good, tri), + ])) + _, parts, _ = load_model(p) + assert len(parts) == 3, f"#2: names collapsed: {sorted(parts)}" + + print("test_3ds: OK — corrupt-input + duplicate-name robustness verified.") + + +if __name__ == "__main__": + main() + robustness()