IMPORT: 3D Studio files

A .3ds file opens the 3D Studio Import window: the file's objects with
their kind and material count, filtered by kind or name, checked one by
one or all at once, where to put them on layers and what a mesh with
several materials becomes. Checked meshes come in as polyface meshes on
the chosen layers with materials made from the file's colours, cameras
as named views; a mesh with several materials is split, given its first
material, given none, or asked about on the command line. Automation
drives the window with tds_import_dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-10-16 20:12:16 +03:00
1 parent 9ddc103bcc
commit 78e429732e
35 files changed
+1893 -4

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ pub mod step;
pub mod step_import;
pub mod stl;
pub mod svg_export;
pub mod tds_import;
pub mod thumbnail;
pub mod update_check;
pub mod visual_styles;
+403
View File
@@ -0,0 +1,403 @@
//! 3D Studio (.3ds) reading: the materials, meshes, lights and cameras of a
//! file's editor section, and their conversion to drawing objects — each
//! mesh a polyface mesh on a layer chosen by object, material, object colour
//! or a single layer, with its material; each camera a named view.
use std::collections::HashMap;
use codec::entities::{PolyfaceFace, PolyfaceMesh, PolyfaceVertex};
use codec::types::Vector3;
pub struct TdsMaterial {
pub name: String,
pub ambient: Option<[u8; 3]>,
pub diffuse: Option<[u8; 3]>,
pub specular: Option<[u8; 3]>,
}
pub struct TdsMesh {
pub vertices: Vec<[f64; 3]>,
pub faces: Vec<[usize; 3]>,
/// Each material the mesh uses and the faces it covers, in file order.
pub groups: Vec<(String, Vec<usize>)>,
/// The object colour, a palette index, when the file gives one.
pub color: Option<u8>,
}
pub struct TdsCamera {
pub position: [f64; 3],
pub target: [f64; 3],
pub lens: f64,
}
pub enum TdsObject {
Mesh(TdsMesh),
Light,
Camera(TdsCamera),
}
impl TdsObject {
/// The kind as the import window lists it.
pub fn kind(&self) -> &'static str {
match self {
TdsObject::Mesh(_) => "Mesh",
TdsObject::Light => "Light",
TdsObject::Camera(_) => "Camera",
}
}
}
pub struct TdsScene {
pub materials: Vec<TdsMaterial>,
pub objects: Vec<(String, TdsObject)>,
}
impl TdsScene {
/// The materials a mesh uses, in the order the file defines them.
pub fn mesh_materials(&self, mesh: &TdsMesh) -> Vec<String> {
let mut used: Vec<String> = Vec::new();
for m in &self.materials {
if mesh.groups.iter().any(|(name, faces)| !faces.is_empty() && *name == m.name) {
used.push(m.name.clone());
}
}
for (name, faces) in &mesh.groups {
if !faces.is_empty() && !used.contains(name) {
used.push(name.clone());
}
}
used
}
}
struct Reader<'a> {
b: &'a [u8],
}
impl Reader<'_> {
fn u16(&self, at: usize) -> Option<u16> {
Some(u16::from_le_bytes([*self.b.get(at)?, *self.b.get(at + 1)?]))
}
fn u32(&self, at: usize) -> Option<u32> {
Some(u32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?))
}
fn f32(&self, at: usize) -> Option<f64> {
Some(f64::from(f32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?)))
}
fn point(&self, at: usize) -> Option<[f64; 3]> {
Some([self.f32(at)?, self.f32(at + 4)?, self.f32(at + 8)?])
}
/// A zero-terminated string and the offset after it.
fn cstr(&self, at: usize, end: usize) -> (String, usize) {
let stop = self.b[at.min(end)..end]
.iter()
.position(|c| *c == 0)
.map_or(end, |k| at + k);
(
String::from_utf8_lossy(&self.b[at.min(stop)..stop]).into_owned(),
(stop + 1).min(end),
)
}
/// The chunks between `from` and `to`: id, body start, body end.
fn chunks(&self, from: usize, to: usize) -> Vec<(u16, usize, usize)> {
let mut out = Vec::new();
let mut at = from;
while at + 6 <= to {
let (Some(id), Some(len)) = (self.u16(at), self.u32(at + 2)) else { break };
let len = len as usize;
if len < 6 || at + len > to {
break;
}
out.push((id, at + 6, at + len));
at += len;
}
out
}
/// A colour chunk's 8-bit RGB: 24-bit or float, plain before linear.
fn color(&self, from: usize, to: usize) -> Option<[u8; 3]> {
let chunks = self.chunks(from, to);
for want in [0x0011, 0x0010, 0x0012, 0x0013] {
for &(id, start, _) in &chunks {
if id != want {
continue;
}
return Some(match id {
0x0011 | 0x0012 => [*self.b.get(start)?, *self.b.get(start + 1)?, *self.b.get(start + 2)?],
_ => self.point(start)?.map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8),
});
}
}
None
}
}
/// `None` when the bytes are no 3D Studio file.
pub fn read(bytes: &[u8]) -> Option<TdsScene> {
let r = Reader { b: bytes };
if r.u16(0)? != 0x4D4D {
return None;
}
let end = (r.u32(2)? as usize).min(bytes.len());
let mut scene = TdsScene {
materials: Vec::new(),
objects: Vec::new(),
};
for (id, start, stop) in r.chunks(6, end) {
if id != 0x3D3D {
continue;
}
for (id, start, stop) in r.chunks(start, stop) {
match id {
0xAFFF => {
let mut material = TdsMaterial {
name: String::new(),
ambient: None,
diffuse: None,
specular: None,
};
for (id, s, e) in r.chunks(start, stop) {
match id {
0xA000 => material.name = r.cstr(s, e).0,
0xA010 => material.ambient = r.color(s, e),
0xA020 => material.diffuse = r.color(s, e),
0xA030 => material.specular = r.color(s, e),
_ => {}
}
}
scene.materials.push(material);
}
0x4000 => {
let (name, body) = r.cstr(start, stop);
for (id, s, e) in r.chunks(body, stop) {
let object = match id {
0x4100 => TdsObject::Mesh(read_mesh(&r, s, e)),
0x4600 => TdsObject::Light,
0x4700 => TdsObject::Camera(TdsCamera {
position: r.point(s)?,
target: r.point(s + 12)?,
lens: r.f32(s + 28).filter(|l| *l > 0.0).unwrap_or(50.0),
}),
_ => continue,
};
scene.objects.push((name.clone(), object));
break;
}
}
_ => {}
}
}
}
Some(scene)
}
fn read_mesh(r: &Reader<'_>, from: usize, to: usize) -> TdsMesh {
let mut mesh = TdsMesh {
vertices: Vec::new(),
faces: Vec::new(),
groups: Vec::new(),
color: None,
};
for (id, s, e) in r.chunks(from, to) {
match id {
0x4110 => {
let n = r.u16(s).unwrap_or(0) as usize;
mesh.vertices = (0..n).filter_map(|k| r.point(s + 2 + 12 * k)).collect();
}
0x4120 => {
let n = r.u16(s).unwrap_or(0) as usize;
mesh.faces = (0..n)
.filter_map(|k| {
let at = s + 2 + 8 * k;
Some([r.u16(at)? as usize, r.u16(at + 2)? as usize, r.u16(at + 4)? as usize])
})
.collect();
for (id, gs, ge) in r.chunks(s + 2 + 8 * n, e) {
if id == 0x4130 {
let (name, at) = r.cstr(gs, ge);
let count = r.u16(at).unwrap_or(0) as usize;
let faces = (0..count).filter_map(|k| r.u16(at + 2 + 2 * k).map(usize::from)).collect();
mesh.groups.push((name, faces));
}
}
}
0x4165 => mesh.color = r.b.get(s).copied(),
_ => {}
}
}
mesh
}
/// Where imported objects go.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LayerMode {
#[default]
ByObject,
ByMaterial,
ByObjectColor,
Single,
}
/// What a mesh with several materials becomes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MultiMaterial {
/// One mesh per material.
Split,
/// One mesh with the first material the file defines.
First,
/// One mesh with this material, or none.
Material(Option<String>),
}
pub struct TdsOptions {
/// The objects to import, by name ("Global Settings" included).
pub selected: Vec<String>,
pub layers: LayerMode,
/// The choice for each mesh with several materials, by name.
pub multi: HashMap<String, MultiMaterial>,
/// Whether meshes keep only the vertices their faces use, in the order
/// they first use them — as they do when the import splits by
/// material or asks — rather than the file's vertex list.
pub compact: bool,
}
/// One imported mesh.
pub struct TdsPart {
pub layer: String,
pub material: Option<String>,
pub mesh: PolyfaceMesh,
}
pub struct TdsCameraView {
pub name: String,
pub target: [f64; 3],
pub direction: [f64; 3],
/// The view height at the target, from the lens.
pub height: f64,
pub lens: f64,
}
pub struct TdsImport {
pub parts: Vec<TdsPart>,
/// The materials the parts use, in the order they were first used.
pub materials: Vec<String>,
pub views: Vec<TdsCameraView>,
pub messages: Vec<String>,
}
/// A polyface mesh of `faces` of `mesh`: its vertices in file order, or
/// (`compact`) only those the faces use, in the order they first use them.
fn polyface(mesh: &TdsMesh, faces: &[usize], compact: bool) -> PolyfaceMesh {
let mut out = PolyfaceMesh::new();
let mut index: HashMap<usize, i16> = HashMap::new();
if !compact {
for (k, v) in mesh.vertices.iter().enumerate() {
index.insert(k, out.add_vertex(PolyfaceVertex::new(Vector3::new(v[0], v[1], v[2]))));
}
}
for &f in faces {
let Some(face) = mesh.faces.get(f) else { continue };
if face.iter().any(|v| *v >= mesh.vertices.len()) {
continue;
}
let ids = face.map(|v| {
*index.entry(v).or_insert_with(|| {
let p = mesh.vertices[v];
out.add_vertex(PolyfaceVertex::new(Vector3::new(p[0], p[1], p[2])))
})
});
out.add_face(PolyfaceFace::triangle(ids[0], ids[1], ids[2]));
}
out
}
/// The view height a camera's lens sees at the target distance.
// ponytail: one measured camera (50 mm at 86.46 units gives 30.457); the
// height is taken as proportional to distance over lens.
fn view_height(distance: f64, lens: f64) -> f64 {
distance * 17.6141 / lens
}
pub fn convert(scene: &TdsScene, options: &TdsOptions) -> TdsImport {
let mut out = TdsImport {
parts: Vec::new(),
materials: Vec::new(),
views: Vec::new(),
messages: Vec::new(),
};
let chosen = |name: &str| options.selected.iter().any(|s| s == name);
// The objects are taken in the order the window lists them, by name.
let mut objects: Vec<&(String, TdsObject)> = scene.objects.iter().collect();
objects.sort_by_key(|(name, _)| name.to_lowercase());
for (name, object) in objects {
if !chosen(name) {
continue;
}
match object {
TdsObject::Mesh(mesh) => {
out.messages.push(format!("Processing object {name}"));
let materials = scene.mesh_materials(mesh);
let all: Vec<usize> = (0..mesh.faces.len()).collect();
let pieces: Vec<(Option<String>, Vec<usize>)> = if materials.len() > 1 {
match options.multi.get(name).cloned().unwrap_or(MultiMaterial::Split) {
MultiMaterial::Split => materials
.iter()
.map(|m| {
let faces = mesh
.groups
.iter()
.filter(|(g, _)| g == m)
.flat_map(|(_, f)| f.iter().copied())
.collect();
(Some(m.clone()), faces)
})
.collect(),
MultiMaterial::First => vec![(materials.first().cloned(), all)],
MultiMaterial::Material(m) => vec![(m, all)],
}
} else {
vec![(materials.first().cloned(), all)]
};
let compact = options.compact || pieces.len() > 1;
for (material, faces) in pieces {
if let Some(m) = &material {
if !out.materials.contains(m) {
out.messages.push(format!("Converting material {m}"));
out.materials.push(m.clone());
}
}
let layer = match options.layers {
LayerMode::ByObject => name.to_uppercase(),
LayerMode::ByMaterial => material.as_deref().unwrap_or("0").to_uppercase(),
LayerMode::ByObjectColor => match mesh.color {
Some(c) => format!("COLOR{c:02}"),
None => "COLORNONE".to_string(),
},
LayerMode::Single => "AVLAYER".to_string(),
};
out.parts.push(TdsPart {
layer,
material,
mesh: polyface(mesh, &faces, compact),
});
}
}
TdsObject::Camera(camera) => {
let name = name.to_uppercase();
out.messages.push(format!("Creating camera {name}"));
let direction = [0, 1, 2].map(|k| camera.position[k] - camera.target[k]);
let distance = direction.iter().map(|d| d * d).sum::<f64>().sqrt();
out.views.push(TdsCameraView {
name,
target: camera.target,
direction,
height: view_height(distance, camera.lens),
lens: camera.lens,
});
}
TdsObject::Light => {}
}
}
out.messages.push("3D Studio file import completed".to_string());
out
}