Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 17 additions & 3 deletions model_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
189 changes: 189 additions & 0 deletions tds_reader.py
Original file line number Diff line number Diff line change
@@ -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("<HI", data, p)
# A chunk length shorter than its own header, or one that would run
# past our parent's bounds, means a malformed/truncated file — stop
# rather than spin or read out of range.
if clen < 6:
break
cstart = p + 6
cend = p + clen
if cend > 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("<H", data, cstart)[0]
off = cstart + 2
verts = []
for i in range(n):
if off + 12 > cend:
break
x, y, z = struct.unpack_from("<fff", data, off)
verts.append((x, y, z))
off += 12
cur.vertices = verts

elif cid == _FACE_LIST:
cur = state["current"]
if cur is not None and cstart + 2 <= cend:
n = struct.unpack_from("<H", data, cstart)[0]
off = cstart + 2
faces = []
for i in range(n):
if off + 8 > cend:
break
a, b, c, _flags = struct.unpack_from("<HHHH", data, off)
faces.append((a, b, c))
off += 8
cur.faces = faces
# After the face array, FACE_LIST may carry sub-chunks
# (material groups 0x4130, smoothing groups 0x4150). We don't
# need them; the outer loop skips to cend via clen.

elif cid == _TEX_COORDS:
cur = state["current"]
if cur is not None and cstart + 2 <= cend:
n = struct.unpack_from("<H", data, cstart)[0]
off = cstart + 2
uvs = []
for i in range(n):
if off + 8 > cend:
break
u, v = struct.unpack_from("<ff", data, off)
uvs.append((u, v))
off += 8
cur.uvs = uvs

# else: chunk we don't interpret — skip it (handled by advancing p).
p = cend


def read_tds(path):
"""Parses a `.3ds` file and returns a list of TdsMesh objects (one per
named mesh with real geometry). Raises TdsParseError if the file isn't a
recognizable 3DS container."""
with open(path, "rb") as f:
data = f.read()

if len(data) < 6:
raise TdsParseError("File is too small to be a 3DS file.")

main_id, main_len = struct.unpack_from("<HI", data, 0)
if main_id != _MAIN:
raise TdsParseError(
f"Not a 3DS file: expected main chunk 0x4D4D, got 0x{main_id:04X}."
)

end = min(main_len, len(data)) if main_len >= 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")
119 changes: 119 additions & 0 deletions tds_to_obj.py
Original file line number Diff line number Diff line change
@@ -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])
4 changes: 2 additions & 2 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ <h1>Orthographic Template Generator</h1>
<div class="section">
<h2>1. Model File</h2>
<div id="drop_zone">Drop a model file or a zipped mod folder here, or click to choose one<br>
<small>(.obj, .dae, .stl, .gltf, .glb, .kn5, or a .zip containing one of those)</small>
<small>(.obj, .dae, .stl, .gltf, .glb, .kn5, .3ds, or a .zip containing one of those)</small>
</div>
<input type="file" id="file_input" class="hidden" accept=".obj,.dae,.stl,.gltf,.glb,.kn5,.zip">
<input type="file" id="file_input" class="hidden" accept=".obj,.dae,.stl,.gltf,.glb,.kn5,.3ds,.zip">
<label style="display:block;margin-top:8px;font-size:0.9em;">
<input type="checkbox" id="keep_uploads_cb"> Keep uploaded model file on disk
</label>
Expand Down
Loading