feat: OBJ mesh import (IMPORTOBJ / OBJIMPORT command)
Parses Wavefront OBJ files (v/vn/f, fan-triangulation, face normals fallback) and inserts the result as a Solid3D placeholder + MeshModel visible in the 3D viewport. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
549ae38459
commit
6fa3157e72
5 changed files with 174 additions and 0 deletions
|
|
@ -3457,6 +3457,11 @@ impl H7CAD {
|
|||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── OBJ import ───────────────────────────────────────────────
|
||||
"IMPORTOBJ"|"OBJIMPORT" => {
|
||||
return Task::done(Message::ObjImport);
|
||||
}
|
||||
|
||||
// ── STL export ────────────────────────────────────────────────
|
||||
"STLOUT"|"EXPORTSTL" => {
|
||||
return Task::done(Message::StlExport);
|
||||
|
|
|
|||
|
|
@ -440,6 +440,11 @@ pub enum Message {
|
|||
StlExport,
|
||||
/// Callback after the user picks (or cancels) the STL save path.
|
||||
StlExportPath(Option<std::path::PathBuf>),
|
||||
// ── OBJ import ────────────────────────────────────────────────────────
|
||||
/// Trigger OBJ import: show open-file dialog.
|
||||
ObjImport,
|
||||
/// Callback after the user picks (or cancels) the OBJ file path.
|
||||
ObjImportPath(Option<std::path::PathBuf>),
|
||||
}
|
||||
|
||||
impl H7CAD {
|
||||
|
|
|
|||
|
|
@ -288,6 +288,61 @@ impl H7CAD {
|
|||
|
||||
Message::StlExportPath(None) => Task::none(),
|
||||
|
||||
// ── OBJ import ────────────────────────────────────────────────
|
||||
Message::ObjImport => {
|
||||
Task::perform(
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Import OBJ Mesh")
|
||||
.add_filter("Wavefront OBJ", &["obj", "OBJ"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|h| h.path().to_path_buf())
|
||||
},
|
||||
Message::ObjImportPath,
|
||||
)
|
||||
}
|
||||
|
||||
Message::ObjImportPath(Some(path)) => {
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&format!("IMPORTOBJ: read error: {e}"));
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let color = [0.7f32, 0.7, 0.85, 1.0];
|
||||
match crate::io::obj::parse_obj(&src, color) {
|
||||
None => {
|
||||
self.command_line.push_error("IMPORTOBJ: no usable geometry in file.");
|
||||
}
|
||||
Some(mut mesh) => {
|
||||
let i = self.active_tab;
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "obj_mesh".into());
|
||||
mesh.name = file_stem.clone();
|
||||
self.push_undo_snapshot(i, "IMPORTOBJ");
|
||||
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
||||
let entity = empty_solid3d();
|
||||
let handle = self.tabs[i].scene.add_entity(entity);
|
||||
if !handle.is_null() {
|
||||
self.tabs[i].scene.meshes.insert(handle, mesh);
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"IMPORTOBJ: imported \"{}\" as mesh.",
|
||||
file_stem
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::ObjImportPath(None) => Task::none(),
|
||||
|
||||
Message::SaveFile => {
|
||||
let i = self.active_tab;
|
||||
if let Some(path) = &self.tabs[i].current_path {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// All file reading/writing goes through acadrust.
|
||||
// Default save format: DWG (AC1032 / R2018+).
|
||||
|
||||
pub mod obj;
|
||||
pub mod pdf_export;
|
||||
pub mod plot_style;
|
||||
pub mod stl;
|
||||
|
|
|
|||
108
src/io/obj.rs
Normal file
108
src/io/obj.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Wavefront OBJ mesh importer.
|
||||
//
|
||||
// Parses vertex positions, optional normals, and triangle/quad faces.
|
||||
// Quads are split into two triangles.
|
||||
// Only the first object/group is imported (no multi-object support needed).
|
||||
|
||||
use crate::scene::mesh_model::MeshModel;
|
||||
|
||||
/// Parse OBJ text into a MeshModel.
|
||||
/// Returns `None` if the file has no usable geometry.
|
||||
pub fn parse_obj(src: &str, color: [f32; 4]) -> Option<MeshModel> {
|
||||
let mut positions: Vec<[f32; 3]> = Vec::new();
|
||||
let mut normals_raw: Vec<[f32; 3]> = Vec::new();
|
||||
// Each face vertex: (pos_idx, normal_idx_opt)
|
||||
let mut face_verts: Vec<(usize, Option<usize>)> = Vec::new();
|
||||
|
||||
for line in src.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || line.is_empty() { continue; }
|
||||
|
||||
let mut parts = line.split_whitespace();
|
||||
let keyword = parts.next().unwrap_or("");
|
||||
|
||||
match keyword {
|
||||
"v" => {
|
||||
let vals: Vec<f32> = parts.filter_map(|s| s.parse().ok()).collect();
|
||||
if vals.len() >= 3 {
|
||||
// OBJ: Y-up right-handed. H7CAD viewport is also Y-up → keep as-is.
|
||||
positions.push([vals[0], vals[1], vals[2]]);
|
||||
}
|
||||
}
|
||||
"vn" => {
|
||||
let vals: Vec<f32> = parts.filter_map(|s| s.parse().ok()).collect();
|
||||
if vals.len() >= 3 {
|
||||
normals_raw.push([vals[0], vals[1], vals[2]]);
|
||||
}
|
||||
}
|
||||
"f" => {
|
||||
// Collect vertex descriptors "v", "v/vt", "v/vt/vn", "v//vn"
|
||||
let descs: Vec<(usize, Option<usize>)> = parts
|
||||
.filter_map(|token| {
|
||||
let mut it = token.split('/');
|
||||
let pos_i: usize = it.next()?.parse::<i32>().ok()
|
||||
.map(|i| if i < 0 { positions.len() as i32 + i } else { i - 1 })? as usize;
|
||||
it.next(); // skip vt
|
||||
let norm_i = it.next().and_then(|s| s.parse::<i32>().ok())
|
||||
.map(|i| if i < 0 { normals_raw.len() as i32 + i } else { i - 1 } as usize);
|
||||
Some((pos_i, norm_i))
|
||||
})
|
||||
.collect();
|
||||
// Fan-triangulate: (0,1,2), (0,2,3), …
|
||||
for k in 1..(descs.len() as isize - 1) {
|
||||
face_verts.push(descs[0]);
|
||||
face_verts.push(descs[k as usize]);
|
||||
face_verts.push(descs[k as usize + 1]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if positions.is_empty() || face_verts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Build flat (un-indexed) vertex + normal arrays.
|
||||
let mut verts: Vec<[f32; 3]> = Vec::with_capacity(face_verts.len());
|
||||
let mut norms: Vec<[f32; 3]> = Vec::with_capacity(face_verts.len());
|
||||
let mut indices: Vec<u32> = Vec::with_capacity(face_verts.len());
|
||||
|
||||
for (vi, (pos_i, norm_i)) in face_verts.iter().enumerate() {
|
||||
let pos = *positions.get(*pos_i).unwrap_or(&[0.0; 3]);
|
||||
verts.push(pos);
|
||||
let norm = norm_i
|
||||
.and_then(|ni| normals_raw.get(ni).copied())
|
||||
.unwrap_or([0.0, 0.0, 0.0]);
|
||||
norms.push(norm);
|
||||
indices.push(vi as u32);
|
||||
}
|
||||
|
||||
// If no normals were provided in the OBJ file, compute face normals.
|
||||
if normals_raw.is_empty() {
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let a = verts[tri[0] as usize];
|
||||
let b = verts[tri[1] as usize];
|
||||
let c = verts[tri[2] as usize];
|
||||
let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]];
|
||||
let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]];
|
||||
let nx = ab[1]*ac[2] - ab[2]*ac[1];
|
||||
let ny = ab[2]*ac[0] - ab[0]*ac[2];
|
||||
let nz = ab[0]*ac[1] - ab[1]*ac[0];
|
||||
let len = (nx*nx + ny*ny + nz*nz).sqrt().max(1e-12);
|
||||
let n = [nx/len, ny/len, nz/len];
|
||||
norms[tri[0] as usize] = n;
|
||||
norms[tri[1] as usize] = n;
|
||||
norms[tri[2] as usize] = n;
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
normals: norms,
|
||||
indices,
|
||||
color,
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue