diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 8b8a8ab88..556d5c5a9 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -1107,6 +1107,75 @@ impl H7CAD { self.restore_pre_cmd_tangent(); } + // ── LOFT ─────────────────────────────────────────────────────── + CmdResult::LoftEntities { handles, color } => { + use crate::entities::traits::EntityTypeOps; + use crate::scene::acad_to_truck::TruckObject; + use crate::scene::truck_tess; + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use truck_modeling::builder; + + // Collect wires from each profile. + let mut wires: Vec = Vec::new(); + for h in &handles { + if let Some(ent) = self.tabs[i].scene.document.get_entity(*h).cloned() { + if let Some(te) = ent.to_truck_entity(&self.tabs[i].scene.document) { + let wire = match te.object { + TruckObject::Contour(w) => Some(w), + TruckObject::Curve(e) => Some(std::iter::once(e).collect()), + _ => None, + }; + if let Some(w) = wire { wires.push(w); } + } + } + } + + let result: Option = (|| { + if wires.len() < 2 { return None; } + + // Build ruled shells between consecutive profile pairs. + let mut all_faces: Vec = Vec::new(); + + for pair in wires.windows(2) { + let shell = builder::try_wire_homotopy(&pair[0], &pair[1]).ok()?; + for face in shell.into_iter() { all_faces.push(face); } + } + + // Cap the first and last profiles if they are closed. + if let Ok(cap) = builder::try_attach_plane(&[wires.first()?.clone()]) { + all_faces.push(cap); + } + if let Ok(cap) = builder::try_attach_plane(&[wires.last()?.clone()]) { + all_faces.push(cap); + } + + let shell = truck_modeling::Shell::from(all_faces); + match truck_tess::tessellate_shell(&shell) { + truck_tess::TruckTessResult::Mesh { verts, normals, indices } => + Some(crate::scene::mesh_model::MeshModel { + name: String::new(), verts, normals, indices, color, selected: false, + }), + _ => None, + } + })(); + + if let Some(mut mesh) = result { + self.push_undo_snapshot(i, "LOFT"); + let new_entity = empty_solid3d(); + let new_handle = self.tabs[i].scene.add_entity(new_entity); + mesh.name = format!("{}", new_handle.value()); + self.tabs[i].scene.meshes.insert(new_handle, mesh); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("LOFT: solid created from {} profiles.", handles.len())); + } else { + self.command_line.push_error("LOFT: could not loft profiles. Ensure sections have the same edge count and are compatible."); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + CmdResult::HatcheditApply { handle, name, scale, angle } => { if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() { // Update model fields diff --git a/src/app/commands.rs b/src/app/commands.rs index 91fb3b174..e6b4cb8cd 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -3466,6 +3466,15 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + // ── LOFT ─────────────────────────────────────────────────────── + "LOFT" => { + use crate::modules::insert::solid3d_cmds::LoftCommand; + let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); + let cmd = LoftCommand::new(color); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + // ── OBJ import ─────────────────────────────────────────────── "IMPORTOBJ"|"OBJIMPORT" => { return Task::done(Message::ObjImport); diff --git a/src/command/mod.rs b/src/command/mod.rs index cb846a38b..776d15f06 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -159,6 +159,11 @@ pub enum CmdResult { path_handle: Handle, color: [f32; 4], }, + /// Loft through a series of profile entities. + LoftEntities { + handles: Vec, + color: [f32; 4], + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 7b5e27a34..4a334341b 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -415,6 +415,49 @@ impl CadCommand for SweepCommand { fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } } +// ── LOFT command ─────────────────────────────────────────────────────────── + +pub struct LoftCommand { + profiles: Vec, + color: [f32; 4], +} + +impl LoftCommand { + pub fn new(color: [f32; 4]) -> Self { + Self { profiles: Vec::new(), color } + } +} + +impl CadCommand for LoftCommand { + fn name(&self) -> &'static str { "LOFT" } + fn prompt(&self) -> String { + if self.profiles.is_empty() { + "LOFT Select first cross-section:".into() + } else { + format!("LOFT Select next cross-section ({} selected, Enter to finish):", self.profiles.len()) + } + } + fn needs_entity_pick(&self) -> bool { true } + fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: Vec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + // Avoid duplicate picks. + if !self.profiles.contains(&handle) { + self.profiles.push(handle); + } + CmdResult::NeedPoint + } + fn on_point(&mut self, _pt: Vec3) -> CmdResult { CmdResult::NeedPoint } + fn wants_text_input(&self) -> bool { self.profiles.len() >= 2 } + fn on_text_input(&mut self, _text: &str) -> Option { None } + fn on_enter(&mut self) -> CmdResult { + if self.profiles.len() < 2 { + CmdResult::Cancel + } else { + CmdResult::LoftEntities { handles: self.profiles.clone(), color: self.color } + } + } +} + // ── Placeholder Solid3D entity construction ──────────────────────────────── /// Create a minimal Solid3D entity with empty ACIS data (placeholder only).