forked from 1b-agent/opencadstudio-upstream
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:
1 parent
9ddc103bcc
commit
78e429732e
35 files changed
+1893
-4
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user