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

+5
View File
@@ -654,6 +654,11 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
return self.run_dgn_import(i, request);
}
CmdResult::TdsImport(request) => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.run_tds_import(i, request);
}
CmdResult::PdfImportFile(import) => {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
+74
View File
@@ -65,6 +65,7 @@ enum Route {
Dgn,
Model(ModelFormat),
Wmf,
Tds,
}
/// The importer for `path` chosen as `format` (an index of [`FORMATS`]; `None`
@@ -91,6 +92,7 @@ fn route(path: &str, format: Option<usize>) -> Result<Route, String> {
Some("STEP") => return Ok(Route::Model(ModelFormat::Step)),
Some("IGES") => return Ok(Route::Model(ModelFormat::Iges)),
Some("Metafile") => return Ok(Route::Wmf),
Some("3D Studio") => return Ok(Route::Tds),
_ => {}
}
let pdf = if by_extension { ext == "pdf" } else { f.is_some_and(|f| f.name == "PDF Files") };
@@ -245,6 +247,14 @@ impl OpenCADStudio {
let i = self.active_tab;
self.import_wmf(i, path, false)
}
// The options window opens whatever FILEDIA is.
Ok(Route::Tds) => {
self.remember_import(path);
if let Err(error) = self.open_tds_import_dialog(path) {
self.command_line.push_error(&error);
}
Task::none()
}
Err(error) => {
self.command_line.push_error(&error);
Task::none()
@@ -260,6 +270,7 @@ impl OpenCADStudio {
"dgn" => crate::t!(
"Imports a DGN model as drawing objects: levels as layers, cells as blocks."
),
"3ds" => std::borrow::Cow::Owned(crate::tr!("import-3ds", "help")),
_ => crate::t!(
"Imports a file of the chosen format with the format's importer."
),
@@ -362,8 +373,71 @@ impl OpenCADStudio {
self.close_active_modal();
return self.import_wmf(i, &path, false);
}
Ok(Route::Tds) => {
self.remember_import(&path);
self.close_active_modal();
if let Err(error) = self.open_tds_import_dialog(&path) {
self.command_line.push_error(&error);
}
}
}
}
// ── 3D Studio Import ─────────────────────────────────────────
ImportDialogMsg::TdsChange => {
let title = crate::t!("Import File").into_owned();
let filters = vec![("3D Studio (*.3ds)".to_string(), vec!["3ds".to_string()])];
return Task::perform(pick(title, filters), |path| {
Message::ImportDialog(ImportDialogMsg::TdsPicked(path))
});
}
ImportDialogMsg::TdsPicked(Some(path)) => {
if let Err(error) = self.open_tds_import_dialog(&path) {
self.command_line.push_error(&error);
}
}
ImportDialogMsg::TdsPicked(None) => {}
ImportDialogMsg::TdsCheck(k, on) => {
if let Some(c) = self.import_dialogs.tds.as_mut().and_then(|s| s.checked.get_mut(k)) {
*c = on;
}
}
ImportDialogMsg::TdsAll | ImportDialogMsg::TdsClear => {
let on = matches!(message, ImportDialogMsg::TdsAll);
if let Some(s) = self.import_dialogs.tds.as_mut() {
s.checked.iter_mut().for_each(|c| *c = on);
}
}
ImportDialogMsg::TdsFilter(k) => {
if let Some(s) = self.import_dialogs.tds.as_mut() {
s.filter = k.min(4);
}
}
ImportDialogMsg::TdsSearch(text) => {
if let Some(s) = self.import_dialogs.tds.as_mut() {
s.search = text;
}
}
ImportDialogMsg::TdsLayers(k) => {
if let Some(s) = self.import_dialogs.tds.as_mut() {
s.layers = k.min(3);
}
}
ImportDialogMsg::TdsMulti(k) => {
if let Some(s) = self.import_dialogs.tds.as_mut() {
s.multi = k.min(3);
}
}
ImportDialogMsg::TdsOk => {
let Some(state) = self.import_dialogs.tds.take() else {
return Task::none();
};
if !state.checked.iter().any(|c| *c) {
self.import_dialogs.tds = Some(state);
return Task::none();
}
self.close_active_modal();
return self.start_tds_import(i, state);
}
// ── DGN Import ───────────────────────────────────────────────
ImportDialogMsg::DgnChange => return self.pick_dgn_file(),
ImportDialogMsg::DgnPicked(Some(path)) => {
+214
View File
@@ -203,3 +203,217 @@ impl OpenCADStudio {
Task::none()
}
}
impl OpenCADStudio {
/// The 3D Studio Import window for a file; an error when it is no 3D
/// Studio file.
pub(in crate::app) fn open_tds_import_dialog(&mut self, path: &str) -> Result<(), String> {
let scene = crate::scene::model::pdf_raster::source_bytes(path)
.and_then(|bytes| crate::io::tds_import::read(&bytes))
.ok_or_else(|| format!("{} is not a valid 3D Studio file.", path.replace('/', "\\")))?;
let mut objects: Vec<(String, &'static str, usize)> = scene
.objects
.iter()
.map(|(name, object)| {
let materials = match object {
crate::io::tds_import::TdsObject::Mesh(mesh) => scene.mesh_materials(mesh).len(),
_ => 0,
};
(name.clone(), object.kind(), materials)
})
.collect();
objects.sort_by_key(|o| o.0.to_lowercase());
objects.insert(0, ("Global Settings".to_string(), "Global", 0));
// The layer and material choices made last stay; nothing starts
// checked.
let previous = self.import_dialogs.tds.take();
self.import_dialogs.tds = Some(crate::ui::window::import_dialogs::TdsImportState {
path: path.to_string(),
checked: vec![false; objects.len()],
objects,
materials: scene.materials.len(),
filter: 0,
search: String::new(),
layers: previous.as_ref().map_or(0, |s| s.layers),
multi: previous.as_ref().map_or(0, |s| s.multi),
});
self.active_modal = Some(crate::app::ModalKind::TdsImport);
Ok(())
}
/// The window's choices as an import: asked mesh by mesh first when set
/// to ask and some checked mesh has several materials.
pub(in crate::app) fn start_tds_import(
&mut self,
i: usize,
state: crate::ui::window::import_dialogs::TdsImportState,
) -> Task<Message> {
use crate::io::tds_import::{LayerMode, MultiMaterial, TdsObject};
use crate::modules::insert::tds_import::{TdsImportRequest, TdsMaterialCommand};
let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&state.path)
.and_then(|bytes| crate::io::tds_import::read(&bytes))
else {
return Task::none();
};
let selected: Vec<String> = state
.objects
.iter()
.zip(&state.checked)
.filter(|(_, on)| **on)
.map(|((name, _, _), _)| name.clone())
.collect();
let several: Vec<(String, Vec<String>)> = scene
.objects
.iter()
.filter(|(name, _)| selected.contains(name))
.filter_map(|(name, object)| match object {
TdsObject::Mesh(mesh) => {
let materials = scene.mesh_materials(mesh);
(materials.len() > 1).then(|| (name.clone(), materials))
}
_ => None,
})
.collect();
let choice = match state.multi {
1 => Some(MultiMaterial::Split),
2 => Some(MultiMaterial::First),
3 => Some(MultiMaterial::Material(None)),
_ => None,
};
let mut request = TdsImportRequest {
path: state.path.clone(),
selected,
layers: match state.layers {
1 => LayerMode::ByMaterial,
2 => LayerMode::ByObjectColor,
3 => LayerMode::Single,
_ => LayerMode::ByObject,
},
multi: Default::default(),
compact: matches!(state.multi, 0 | 1),
};
match choice {
Some(choice) => {
for (name, _) in several {
request.multi.insert(name, choice.clone());
}
}
None if !several.is_empty() => {
use crate::command::CadCommand;
let command = TdsMaterialCommand::new(request, several);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Task::none();
}
None => {}
}
self.run_tds_import(i, request);
Task::none()
}
/// The import itself, one undo step: the meshes on their layers with
/// their materials, the cameras as named views.
pub(in crate::app) fn run_tds_import(
&mut self,
i: usize,
request: crate::modules::insert::tds_import::TdsImportRequest,
) {
use codec::objects::ObjectType;
let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&request.path)
.and_then(|bytes| crate::io::tds_import::read(&bytes))
else {
self.command_line.push_error(&format!(
"{} is not a valid 3D Studio file.",
request.path.replace('/', "\\")
));
return;
};
let import = crate::io::tds_import::convert(
&scene,
&crate::io::tds_import::TdsOptions {
selected: request.selected.clone(),
layers: request.layers,
multi: request.multi.clone(),
compact: request.compact,
},
);
self.push_undo_snapshot(i, "IMPORT");
let document = &mut self.tabs[i].scene.document;
// Materials by name: the drawing's own when it has one, else one
// made from the file's colours.
let mut materials: std::collections::HashMap<String, codec::Handle> = Default::default();
for name in &import.materials {
let existing = document.objects.iter().find_map(|(h, o)| match o {
ObjectType::Material(m) if m.name.eq_ignore_ascii_case(name) => Some(*h),
_ => None,
});
let handle = match existing {
Some(h) => h,
None => {
let source = scene.materials.iter().find(|m| m.name == *name);
let mut material = codec::objects::Material::new();
material.handle = document.allocate_handle();
material.owner = document.header.acad_material_dict_handle;
material.name = name.clone();
let color = |rgb: Option<[u8; 3]>| match rgb {
Some([r, g, b]) => codec::objects::MaterialColor {
flag: 1,
factor: 1.0,
rgb: Some(
((0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b))
as i32,
),
},
None => codec::objects::MaterialColor::default(),
};
material.ambient_color = color(source.and_then(|m| m.ambient));
material.diffuse_color = color(source.and_then(|m| m.diffuse));
material.specular_color = color(source.and_then(|m| m.specular));
let handle = material.handle;
let dictionary = document.header.acad_material_dict_handle;
if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) {
dictionary.add_entry(name.clone(), handle);
}
document.objects.insert(handle, ObjectType::Material(material));
handle
}
};
materials.insert(name.clone(), handle);
}
for part in &import.parts {
if !document.layers.contains(&part.layer) {
let mut layer = codec::tables::Layer::new(part.layer.as_str());
layer.handle = document.allocate_handle();
let _ = document.layers.add(layer);
}
}
for view in &import.views {
let mut named = codec::tables::View::new(view.name.as_str());
named.target = codec::types::Vector3::new(view.target[0], view.target[1], view.target[2]);
named.direction =
codec::types::Vector3::new(view.direction[0], view.direction[1], view.direction[2]);
named.height = view.height;
named.width = view.height;
named.lens_length = view.lens;
named.handle = match document.views.get(&view.name) {
Some(old) => old.handle,
None => document.allocate_handle(),
};
document.views.add_or_replace(named);
}
for part in import.parts {
let mut mesh = part.mesh;
mesh.common.layer = part.layer.clone();
mesh.common.material_handle = part.material.as_ref().and_then(|m| materials.get(m).copied());
if mesh.common.material_handle.is_some() {
mesh.common.material_flags = 0b11;
}
self.tabs[i].scene.add_entity(codec::EntityType::PolyfaceMesh(mesh));
}
for line in &import.messages {
self.command_line.push_output(line);
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
}
+40
View File
@@ -110,6 +110,7 @@ pub(super) const NAMES: &[&str] = &[
"image_adjust",
"import_file_dialog",
"dgn_import_dialog",
"tds_import_dialog",
"refedit_dialog",
"pdf_shx_settings",
"pdf_layer_toggle",
@@ -2198,6 +2199,45 @@ impl OpenCADStudio {
_ => return Err(bad()),
})
}
// The 3D Studio Import window, one field per call: "check=<name>"
// and "uncheck=<name>" (the object's name, or "Global Settings"),
// "all", "clear", "filter=all|mesh|light|camera|general",
// "search=<text>", "layers=object|material|color|single",
// "multi=ask|split|first|none", "path=<file>" (as Change… picks
// it), "ok" or "cancel".
"tds_import_dialog" => {
use crate::ui::window::import_dialogs::ImportDialogMsg as I;
if self.active_modal != Some(crate::app::ModalKind::TdsImport) {
return Err(failure("no_dialog", "The 3D Studio Import window is not open"));
}
let spec = string(req, "value")?;
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
let bad = || failure("invalid_value", spec);
if key == "cancel" {
return Ok(Task::done(Message::CloseModal));
}
let pick = |options: &[&str]| options.iter().position(|o| *o == value).ok_or_else(bad);
Message::ImportDialog(match key {
"ok" => I::TdsOk,
"all" => I::TdsAll,
"clear" => I::TdsClear,
"check" | "uncheck" => {
let k = self
.import_dialogs
.tds
.as_ref()
.and_then(|s| s.objects.iter().position(|(name, _, _)| name.eq_ignore_ascii_case(value)))
.ok_or_else(bad)?;
I::TdsCheck(k, key == "check")
}
"filter" => I::TdsFilter(pick(&["all", "mesh", "light", "camera", "general"])?),
"search" => I::TdsSearch(value.to_string()),
"layers" => I::TdsLayers(pick(&["object", "material", "color", "single"])?),
"multi" => I::TdsMulti(pick(&["ask", "split", "first", "none"])?),
"path" => I::TdsPicked(Some(value.to_string())),
_ => return Err(bad()),
})
}
// The DGN Import window, one field per call: "path=<file>" (as
// Change… picks it), "model=<n>", "target=new|current",
// "conflict=prefix|ignore", "units=master|sub", "explode=on|off",
+2
View File
@@ -2335,6 +2335,8 @@ pub enum ModalKind {
ImportFile,
/// DGNIMPORT's DGN Import window.
DgnImport,
/// IMPORT's 3D Studio Import window.
TdsImport,
/// Web: a crash-recovery autosave snapshot newer than the drawing's last
/// save awaits a restore/discard decision. Distinct from the corrupt-file
/// `RecoveryPrompt` — nothing is wrong with the drawing itself.
+1
View File
@@ -254,6 +254,7 @@ impl OpenCADStudio {
Some(PdfImportFile) => self.pdf_import_file = None,
Some(ImportFile) => self.import_dialogs.file = None,
Some(DgnImport) => self.import_dialogs.dgn = None,
Some(TdsImport) => self.import_dialogs.tds = None,
Some(XrefAttach) => self.xref_attach = None,
Some(WriteBlock) => self.wblock = None,
Some(Hyperlink) => {
+7
View File
@@ -51,6 +51,7 @@ impl OpenCADStudio {
Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(),
Some(K::ImportFile) => crate::t!("Import File").into_owned(),
Some(K::DgnImport) => crate::t!("DGN Import").into_owned(),
Some(K::TdsImport) => crate::tr!("import-3ds", "title"),
Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(),
Some(K::WriteBlock) => crate::t!("Write Block").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
@@ -613,6 +614,12 @@ impl OpenCADStudio {
crate::ui::window::import_dialogs::view_import_file(state, recent, flow)
})
}
super::super::ModalKind::TdsImport => {
let state = self.import_dialogs.tds.as_ref()?;
sized_flow(ex, 860, 560, |flow| {
crate::ui::window::import_dialogs::view_tds_import(state, flow)
})
}
super::super::ModalKind::DgnImport => {
let state = self.import_dialogs.dgn.as_ref()?;
sized_flow(ex, 900, 680, |flow| {