From 6fa3157e7210a0106bdfdfe6ec1e2b1b2d1cb6cb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Apr 2026 12:59:27 +0300 Subject: [PATCH] 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 --- src/app/commands.rs | 5 ++ src/app/mod.rs | 5 ++ src/app/update.rs | 55 ++++++++++++++++++++++ src/io/mod.rs | 1 + src/io/obj.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 src/io/obj.rs diff --git a/src/app/commands.rs b/src/app/commands.rs index 0d08b5893..fe27b0a4f 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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); diff --git a/src/app/mod.rs b/src/app/mod.rs index 2523d7cfe..30d292540 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -440,6 +440,11 @@ pub enum Message { StlExport, /// Callback after the user picks (or cancels) the STL save path. StlExportPath(Option), + // ── OBJ import ──────────────────────────────────────────────────────── + /// Trigger OBJ import: show open-file dialog. + ObjImport, + /// Callback after the user picks (or cancels) the OBJ file path. + ObjImportPath(Option), } impl H7CAD { diff --git a/src/app/update.rs b/src/app/update.rs index 176ebf80e..055127f18 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 { diff --git a/src/io/mod.rs b/src/io/mod.rs index 77335b0c1..664bd7392 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -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; diff --git a/src/io/obj.rs b/src/io/obj.rs new file mode 100644 index 000000000..7464f06d2 --- /dev/null +++ b/src/io/obj.rs @@ -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 { + 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)> = 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 = 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 = 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)> = parts + .filter_map(|token| { + let mut it = token.split('/'); + let pos_i: usize = it.next()?.parse::().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::().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 = 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, + }) +}