From 44c94d1da19193b9c06c8e4fd8ce4dd946484337 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:39:23 +0300 Subject: [PATCH 01/16] Avoid surface meshes in live sweep previews --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/modules/insert/solid3d_cmds.rs | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 671ff7c53..30020efbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f#5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1a1505c7bb8e26770f14b49859587660c7519a25#1a1505c7bb8e26770f14b49859587660c7519a25" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1e2ce7451..5fc7e2fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1a1505c7bb8e26770f14b49859587660c7519a25", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 21b8c8fdf..4a1ddb76f 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -168,7 +168,9 @@ fn preview_body_wires( color: [f32; 4], isolines: usize, ) -> Vec { - let mesh = cadkernel::brep::mesh::tessellate( + // This overlay only consumes curves. Do not triangulate the body's faces + // on every cursor event just to discard the resulting surface mesh. + let wireframe = cadkernel::brep::mesh::tessellate_wireframe( body, cadkernel::brep::mesh::TessellationTolerance::new( cadkernel::tessellation::DEFAULT_ANGLE, @@ -176,7 +178,7 @@ fn preview_body_wires( ) .with_isolines(isolines), ); - let mut wires = mesh + let mut wires = wireframe .edges .into_iter() .filter(|edge| edge.positions.len() >= 2) @@ -190,7 +192,7 @@ fn preview_body_wires( }) .collect::>(); wires.extend( - mesh.isolines + wireframe.isolines .into_iter() .filter(|line| line.positions.len() >= 2) .map(|line| { From ad47b17603a7239e9eb55fe97c9cc3375f0e0457 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:15:44 +0300 Subject: [PATCH 02/16] Complete SWEEP command options and kernel integration --- Cargo.lock | 6 +- Cargo.toml | 6 +- src/app/command_driver.rs | 125 +++++--- src/app/commands/display.rs | 11 +- src/app/update/viewport.rs | 9 + src/command.rs | 39 ++- src/entities/object_data.rs | 1 + src/entities/solid3d.rs | 1 + src/modules/insert/solid3d_cmds.rs | 411 +++++++++++++++++++++++-- src/scene/model/mod.rs | 1 + src/scene/model/solid_history.rs | 73 ++--- src/scene/model/sweep_command_model.rs | 113 +++++++ src/scene/model/sweep_model.rs | 29 +- 13 files changed, 709 insertions(+), 116 deletions(-) create mode 100644 src/scene/model/sweep_command_model.rs diff --git a/Cargo.lock b/Cargo.lock index 30020efbb..b647c57cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,8 +71,8 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" -version = "0.5.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=393d6c0857447f9ca678bed97a612707f0a842c6#393d6c0857447f9ca678bed97a612707f0a842c6" +version = "0.5.3" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=305e93d4cf8ff70ce8449015edb201c2765fcc04#305e93d4cf8ff70ce8449015edb201c2765fcc04" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1a1505c7bb8e26770f14b49859587660c7519a25#1a1505c7bb8e26770f14b49859587660c7519a25" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=209df414c9c0434af00edabc5529c6c774091f0e#209df414c9c0434af00edabc5529c6c774091f0e" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 5fc7e2fa3..0889b94f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1a1505c7bb8e26770f14b49859587660c7519a25", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "305e93d4cf8ff70ce8449015edb201c2765fcc04", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "209df414c9c0434af00edabc5529c6c774091f0e", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "305e93d4cf8ff70ce8449015edb201c2765fcc04" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bda0e71b1..2c8d361f1 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -353,6 +353,17 @@ impl OpenCADStudio { self.reset_tracking_after_point(); self.push_ucs_to_cmd(i); } + if let StepInput::EntityPick(handle, _) = &input { + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.inject_before_entity_pick()) + { + if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.inject_picked_entity(entity); + } + } + } + } if let StepInput::SelectionComplete(handles) = &input { let entities = { let scene = &self.tabs[i].scene; @@ -3979,57 +3990,95 @@ impl OpenCADStudio { self.refresh_properties(); } // ── SWEEP ───────────────────────────────────────────────────── - CmdResult::SweepEntity { - profile_handle, + CmdResult::SweepEntities { + handles, path_handle, + mode, + options, color: _, } => { - if self.reject_locked_edit(i, profile_handle) - || self.reject_locked_edit(i, path_handle) - { - self.tabs[i].active_cmd = None; - return Task::none(); - } + use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_history, sweep_model}; - - let profile_ent = self.tabs[i] - .scene - .document - .get_entity(profile_handle) - .cloned(); - let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let result = profile_ent.zip(path_ent).and_then(|(profile, path)| { - let reference_point = sweep_model::profile_of(&profile)?.plane.origin; - let solid = sweep_model::swept(&profile, &path)?; - let history = sweep_model::sweep_history( - &profile, - &path, - 0.0, - reference_point, - ) - .unwrap_or_else(|| solid_history::brep_op(&solid)); - Some((solid, history)) - }); - - if let Some((solid, history)) = result { - let pending = self.begin_undo(i, "SWEEP", 1, true); - let created = self.add_solid_model(empty_solid3d(), solid, history); - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("SWEEP: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + use crate::scene::model::sweep_model; + let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); + let mut profiles = Vec::new(); + let mut failed = 0usize; + for handle in handles { + if handle == path_handle || self.reject_locked_edit(i, handle) { + failed += 1; + continue; + } + if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { + profiles.push((handle, entity)); + } else { + failed += 1; + } + } + let selection = profiles.iter().map(|(_, entity)| entity.clone()).collect::>(); + let options = sweep_model::sweep_selection_options(&selection, options); + let pending = if profiles.is_empty() { + None + } else { + self.begin_undo(i, "SWEEP", profiles.len(), true) + }; + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + for (handle, profile) in profiles { + let result = path.as_ref().zip(options).and_then(|(path, options)| { + let record = sweep_model::sweep_record(&profile, path, options)?; + let (_, _, closed) = cadkernel::acis::sweep_profile_geometry( + record.sweep_entity.as_ref()?, record.sweep_entity_transform, + ).ok()?; + let surface = mode == ExtrudeMode::Surface || !closed; + let body = cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; + Some((body, record, surface)) + }); + let Some((body, record, surface)) = result else { + failed += 1; + continue; + }; + let created = if surface { + self.add_surface_model(sweep_model::swept_surface_entity(&record), body) + } else { + self.add_solid_model( + empty_solid3d(), body, + acadrust::objects::SolidHistoryOperation::Sweep(record), + ) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_sources { + consumed.push(handle); } } + } + if !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output(crate::tf!( + "SWEEP: created %{created} object(s); %{failed} source(s) could not be swept.", + created = created_handles.len(), failed = failed + ).as_ref()); } else { self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + self.refresh_properties(); } // ── LOFT ────────────────────────────────────────────────────── diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index a27dfade3..1d8ab193f 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -814,7 +814,16 @@ impl OpenCADStudio { "SWEEP" => { use crate::modules::insert::solid3d_cmds::SweepCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let cmd = SweepCommand::new(color); + let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let mut cmd = SweepCommand::new(color, isolines); + let selected = self.tabs[i].scene.selected_handles_in_order() + .into_iter() + .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) + .cloned().map(|entity| (handle, entity))) + .collect::>(); + if !selected.is_empty() { + cmd.set_preselection(selected); + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f192d03c0..19141aa5b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2298,6 +2298,15 @@ impl OpenCADStudio { self.tabs[i].scene.set_hover_highlight(None); } let hover_handle = hovered.unwrap_or(acadrust::Handle::NULL); + let wants_entity = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.wants_hover_entity(hover_handle)); + if wants_entity { + if let Some(entity) = self.tabs[i].scene.document.get_entity(hover_handle).cloned() { + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.inject_hover_entity(hover_handle, entity); + } + } + } let shift = self.shift_down; let mut p = self.tabs[i] .active_cmd diff --git a/src/command.rs b/src/command.rs index 518539fb6..3a21fd09a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1142,6 +1142,29 @@ pub enum ExtrudeExtent { Path(Handle), } +/// Construction options shared by SWEEP creation and its live preview. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SweepOptions { + pub align: bool, + pub bank: bool, + pub base_point: Option, + pub scale: f64, + /// Total twist along the path, in radians. + pub twist_angle: f64, +} + +impl Default for SweepOptions { + fn default() -> Self { + Self { + align: true, + bank: false, + base_point: None, + scale: 1.0, + twist_angle: 0.0, + } + } +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1453,10 +1476,12 @@ pub enum CmdResult { mode: ExtrudeMode, color: [f32; 4], }, - /// Sweep the profile entity `profile_handle` along `path_handle`. - SweepEntity { - profile_handle: Handle, + /// Sweep the selected profiles along one path in a single undo step. + SweepEntities { + handles: Vec, path_handle: Handle, + mode: ExtrudeMode, + options: SweepOptions, color: [f32; 4], }, /// Loft through a series of profile entities. @@ -1951,6 +1976,14 @@ pub trait CadCommand: Send { vec![] } + /// Request a source snapshot only when the hovered entity changes. + /// Commands can cache geometry across mouse moves without holding a scene. + fn wants_hover_entity(&self, _handle: Handle) -> bool { + false + } + + fn inject_hover_entity(&mut self, _handle: Handle, _entity: EntityType) {} + /// Called on every mouse-move in the viewport. /// Return `Some(WireModel)` to update the rubber-band preview, `None` to skip. fn on_mouse_move(&mut self, _pt: DVec3) -> Option { diff --git a/src/entities/object_data.rs b/src/entities/object_data.rs index bed43563d..24545b460 100644 --- a/src/entities/object_data.rs +++ b/src/entities/object_data.rs @@ -204,6 +204,7 @@ fn embedded_name(value: Option<&acadrust::entities::EmbeddedEntity>) -> &'static Some(acadrust::entities::EmbeddedEntity::Ellipse(_)) => "Ellipse", Some(acadrust::entities::EmbeddedEntity::Spline(_)) => "Spline", Some(acadrust::entities::EmbeddedEntity::LwPolyline(_)) => "Polyline", + Some(acadrust::entities::EmbeddedEntity::Region(_)) => "Region", Some(acadrust::entities::EmbeddedEntity::Ray(_)) => "Ray", Some(acadrust::entities::EmbeddedEntity::XLine(_)) => "XLine", Some(acadrust::entities::EmbeddedEntity::Unknown { .. }) => "Unknown", diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index e713a61b7..ce06f0b5a 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -371,6 +371,7 @@ fn embedded_name(entity: Option<&acadrust::entities::EmbeddedEntity>) -> &'stati Some(acadrust::entities::EmbeddedEntity::Ellipse(_)) => "Ellipse", Some(acadrust::entities::EmbeddedEntity::Spline(_)) => "Spline", Some(acadrust::entities::EmbeddedEntity::LwPolyline(_)) => "Polyline", + Some(acadrust::entities::EmbeddedEntity::Region(_)) => "Region", Some(acadrust::entities::EmbeddedEntity::Ray(_)) => "Ray", Some(acadrust::entities::EmbeddedEntity::XLine(_)) => "XLine", Some(acadrust::entities::EmbeddedEntity::Unknown { .. }) => "Unknown", diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 4a1ddb76f..319ee8a13 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -6,7 +6,8 @@ use acadrust::{entities::Solid3D, EntityType, Handle}; use glam::DVec3; use crate::command::{ - CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, WorkingPlane, + CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, SweepOptions, + WorkingPlane, }; use crate::scene::WireModel; use crate::t; @@ -1184,24 +1185,118 @@ impl CadCommand for RevolveCommand { pub struct SweepCommand { step: SweepStep, - profile_handle: acadrust::Handle, + profiles: Vec<(Handle, EntityType)>, + injected_path: Option, + hover_path: Option<(Handle, EntityType)>, + preview_key: Option<(Handle, ExtrudeMode, SweepOptions)>, + preview_cache: Vec, + mode: ExtrudeMode, + options: SweepOptions, + reference_start: Option, + reference_length: f64, + new_length_start: Option, + isolines: usize, color: [f32; 4], } -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] enum SweepStep { - PickProfile, + PickProfiles, + Mode, PickPath, + Alignment, + BasePoint, + Scale, + ReferenceLength, + ReferenceEnd, + NewLength, + NewLengthStart, + NewLengthEnd, + Twist, } impl SweepCommand { - pub fn new(color: [f32; 4]) -> Self { + pub fn new(color: [f32; 4], isolines: usize) -> Self { Self { - step: SweepStep::PickProfile, - profile_handle: acadrust::Handle::NULL, + step: SweepStep::PickProfiles, + profiles: Vec::new(), + injected_path: None, + hover_path: None, + preview_key: None, + preview_cache: Vec::new(), + mode: ExtrudeMode::Solid, + options: SweepOptions::default(), + reference_start: None, + reference_length: 1.0, + new_length_start: None, + isolines, color, } } + + pub fn set_preselection(&mut self, profiles: Vec<(Handle, EntityType)>) { + self.set_profiles(profiles); + if !self.profiles.is_empty() { + self.step = SweepStep::PickPath; + } + } + + fn set_profiles(&mut self, profiles: Vec<(Handle, EntityType)>) { + self.profiles.clear(); + for (handle, entity) in profiles { + if !handle.is_null() + && !self.profiles.iter().any(|(selected, _)| *selected == handle) + && crate::scene::model::sweep_model::is_sweep_profile(&entity) + { + self.profiles.push((handle, entity)); + } + } + self.preview_key = None; + self.preview_cache.clear(); + } + + fn contains_profile(&self, handle: Handle) -> bool { + self.profiles.iter().any(|(selected, _)| *selected == handle) + } + + fn selection_options(&self) -> Option { + let profiles = self.profiles.iter().map(|(_, entity)| entity.clone()).collect::>(); + crate::scene::model::sweep_model::sweep_selection_options(&profiles, self.options) + } + + fn finish(&self, path_handle: Handle) -> CmdResult { + CmdResult::SweepEntities { + handles: self.profiles.iter().map(|(handle, _)| *handle).collect(), + path_handle, + mode: self.mode, + options: self.options, + color: self.color, + } + } + + fn set_scale(&mut self, scale: f64) -> bool { + if !scale.is_finite() || scale <= 0.0 { + return false; + } + self.options.scale = scale; + self.step = SweepStep::PickPath; + true + } + + fn set_reference_length(&mut self, length: f64) -> bool { + if !length.is_finite() || length <= 1e-12 { + return false; + } + self.reference_length = length; + self.step = SweepStep::NewLength; + true + } + + fn length_anchor(&self) -> DVec3 { + self.reference_start + .or_else(|| self.selection_options().and_then(|options| options.base_point)) + .unwrap_or(DVec3::ZERO) + } } impl CadCommand for SweepCommand { @@ -1210,37 +1305,301 @@ impl CadCommand for SweepCommand { } fn prompt(&self) -> String { match self.step { - SweepStep::PickProfile => t!("SWEEP Select profile to sweep:").into_owned(), - SweepStep::PickPath => { - t!("SWEEP Select path (Line, Arc, LwPolyline):").into_owned() - } + SweepStep::PickProfiles => t!("SWEEP Select objects to sweep or [Mode] (Enter to finish):").into_owned(), + SweepStep::Mode => format!( + "{} <{}>:", + t!("SWEEP Creation mode [Solid/Surface]"), + if self.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" }, + ), + SweepStep::PickPath => t!("SWEEP Select sweep path or [Alignment/Base point/Scale/Twist]:").into_owned(), + SweepStep::Alignment => format!( + "{} <{}>:", + t!("SWEEP Align sweep object perpendicular to path [Yes/No]"), + if self.options.align { "Yes" } else { "No" }, + ), + SweepStep::BasePoint => t!("SWEEP Specify base point:").into_owned(), + SweepStep::Scale => format!( + "{} <{}>:", t!("SWEEP Enter scale factor or [Reference]"), self.options.scale, + ), + SweepStep::ReferenceLength => format!( + "{} <{}>:", + t!("SWEEP Specify reference length or first point"), + crate::entities::common::format_length(self.reference_length), + ), + SweepStep::ReferenceEnd => t!("SWEEP Specify second reference point:").into_owned(), + SweepStep::NewLength => format!( + "{} <{}>:", + t!("SWEEP Specify new length or [Points]"), + crate::entities::common::format_length(self.reference_length * self.options.scale), + ), + SweepStep::NewLengthStart => t!("SWEEP Specify first point of new length:").into_owned(), + SweepStep::NewLengthEnd => t!("SWEEP Specify second point of new length:").into_owned(), + SweepStep::Twist => format!( + "{} <{}>:", + t!("SWEEP Specify twist angle or [Bank]"), + crate::entities::common::format_angle(self.options.twist_angle), + ), } } - fn needs_entity_pick(&self) -> bool { - true + + fn options(&self) -> Vec { + match self.step { + SweepStep::PickProfiles => vec![CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + SweepStep::Mode => vec![CmdOption::new("Solid", "SOLID"), CmdOption::new("Surface", "SURFACE")], + SweepStep::PickPath => vec![ + CmdOption::new("Alignment", "ALIGNMENT"), + CmdOption::new("Base point", "BASE"), + CmdOption::new("Scale", "SCALE"), + CmdOption::new("Twist", "TWIST"), + ], + SweepStep::Alignment => vec![CmdOption::new("Yes", "YES"), CmdOption::new("No", "NO")], + SweepStep::Scale => vec![CmdOption::new("Reference", "REFERENCE")], + SweepStep::NewLength => vec![CmdOption::new("Points", "POINTS")], + SweepStep::Twist => vec![CmdOption::new("Bank", "BANK")], + _ => Vec::new(), + } } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { + + fn needs_entity_pick(&self) -> bool { + self.step == SweepStep::PickPath + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.step == SweepStep::PickPath + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if self.step != SweepStep::PickPath || handle.is_null() || self.contains_profile(handle) { + self.injected_path = None; + return CmdResult::NeedPoint; + } + let path = self.injected_path.take().or_else(|| { + self.hover_path.as_ref() + .filter(|(hovered, _)| *hovered == handle) + .map(|(_, entity)| entity.clone()) + }); + if path.as_ref().is_some_and(crate::scene::model::sweep_model::is_sweep_path) { + self.finish(handle) + } else { + CmdResult::NeedPoint + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !point.is_finite() { return CmdResult::NeedPoint; } match self.step { - SweepStep::PickProfile => { - self.profile_handle = handle; + SweepStep::BasePoint => { + self.options.base_point = Some(point); self.step = SweepStep::PickPath; - CmdResult::NeedPoint } - SweepStep::PickPath => CmdResult::SweepEntity { - profile_handle: self.profile_handle, - path_handle: handle, - color: self.color, - }, + SweepStep::ReferenceLength => { + self.reference_start = Some(point); + self.step = SweepStep::ReferenceEnd; + } + SweepStep::ReferenceEnd => { + if let Some(start) = self.reference_start { + self.set_reference_length(point.distance(start)); + } + } + SweepStep::NewLength => { + self.set_scale(point.distance(self.length_anchor()) / self.reference_length); + } + SweepStep::NewLengthStart => { + self.new_length_start = Some(point); + self.step = SweepStep::NewLengthEnd; + } + SweepStep::NewLengthEnd => { + if let Some(start) = self.new_length_start { + self.set_scale(point.distance(start) / self.reference_length); + } + } + _ => {} } - } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } + + fn wants_text_input(&self) -> bool { + matches!(self.step, + SweepStep::PickProfiles | SweepStep::Mode | SweepStep::PickPath + | SweepStep::Alignment | SweepStep::Scale | SweepStep::ReferenceLength + | SweepStep::NewLength | SweepStep::Twist + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, SweepStep::PickPath | SweepStep::ReferenceLength | SweepStep::NewLength) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim(); + if value.is_empty() { + return None; + } + let keyword = value.to_ascii_uppercase(); + match self.step { + SweepStep::PickProfiles if "MODE".starts_with(&keyword) => { + self.step = SweepStep::Mode; + } + SweepStep::Mode => { + if "SOLID".starts_with(&keyword) { + self.mode = ExtrudeMode::Solid; + } else if "SURFACE".starts_with(&keyword) { + self.mode = ExtrudeMode::Surface; + } else { + return Some(CmdResult::NeedPoint); + } + self.step = SweepStep::PickProfiles; + } + SweepStep::PickPath => { + self.step = if "ALIGNMENT".starts_with(&keyword) { + SweepStep::Alignment + } else if "BASE".starts_with(&keyword) || "BASE POINT".starts_with(&keyword) { + SweepStep::BasePoint + } else if "SCALE".starts_with(&keyword) { + SweepStep::Scale + } else if "TWIST".starts_with(&keyword) { + SweepStep::Twist + } else { + // Unconsumed text can be an object handle from automation. + return None; + }; + } + SweepStep::Alignment => { + if "YES".starts_with(&keyword) { + self.options.align = true; + } else if "NO".starts_with(&keyword) { + self.options.align = false; + } else { + return Some(CmdResult::NeedPoint); + } + self.step = SweepStep::PickPath; + } + SweepStep::Scale => { + if "REFERENCE".starts_with(&keyword) { + self.reference_start = None; + self.new_length_start = None; + self.step = SweepStep::ReferenceLength; + } else if let Ok(scale) = value.parse::() { + self.set_scale(scale); + } + } + SweepStep::ReferenceLength => { + if let Some(length) = crate::entities::common::parse_typed_length(value) { + self.set_reference_length(length); + } else { + return None; + } + } + SweepStep::NewLength => { + if "POINTS".starts_with(&keyword) { + self.step = SweepStep::NewLengthStart; + } else if let Some(length) = crate::entities::common::parse_typed_length(value) { + self.set_scale(length / self.reference_length); + } else { + return None; + } + } + SweepStep::Twist => { + if "BANK".starts_with(&keyword) { + self.options.bank = true; + self.step = SweepStep::PickPath; + } else if let Some(angle) = crate::entities::common::parse_angle(value) { + if angle.is_finite() { + self.options.twist_angle = angle; + self.options.bank = false; + self.step = SweepStep::PickPath; + } + } + } + _ => return None, + } + Some(CmdResult::NeedPoint) + } + fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + SweepStep::PickProfiles if !self.profiles.is_empty() => { + self.step = SweepStep::PickPath; + } + SweepStep::Mode => self.step = SweepStep::PickProfiles, + SweepStep::Alignment | SweepStep::Scale | SweepStep::Twist + | SweepStep::NewLength => self.step = SweepStep::PickPath, + SweepStep::ReferenceLength => self.step = SweepStep::NewLength, + _ => return CmdResult::Cancel, + } + CmdResult::NeedPoint + } + + fn is_selection_gathering(&self) -> bool { + self.step == SweepStep::PickProfiles + } + + fn selection_forces_add(&self) -> bool { + self.step == SweepStep::PickProfiles + } + + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step == SweepStep::PickProfiles { + self.set_profiles(entities.into_iter().map(|entry| (entry.handle, entry.entity)).collect()); + } + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn inject_before_entity_pick(&self) -> bool { + self.step == SweepStep::PickPath + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + if self.step == SweepStep::PickPath { + self.injected_path = Some(entity); + } + } + + fn wants_hover_entity(&self, handle: Handle) -> bool { + self.step == SweepStep::PickPath + && !handle.is_null() + && !self.contains_profile(handle) + && self.hover_path.as_ref().is_none_or(|(hovered, _)| *hovered != handle) + } + + fn inject_hover_entity(&mut self, handle: Handle, entity: EntityType) { + self.hover_path = Some((handle, entity)); + self.preview_key = None; + } + + fn on_hover_entity(&mut self, handle: Handle, _point: DVec3) -> Vec { + if self.step != SweepStep::PickPath || handle.is_null() || self.contains_profile(handle) { + return Vec::new(); + } + let key = (handle, self.mode, self.options); + if self.preview_key == Some(key) { + return self.preview_cache.clone(); + } + self.preview_cache.clear(); + let Some((hovered, path)) = self.hover_path.as_ref() else { + return Vec::new(); + }; + if *hovered != handle { + return Vec::new(); + } + self.preview_key = Some(key); + if !crate::scene::model::sweep_model::is_sweep_path(path) { + return Vec::new(); + } + let Some(options) = self.selection_options() else { + return Vec::new(); + }; + self.preview_cache = self.profiles.iter().flat_map(|(_, profile)| { + crate::scene::model::sweep_model::swept_with_options(profile, path, self.mode, options) + .map(|body| preview_body_wires(&body, self.color, self.isolines)) + .unwrap_or_default() + }).collect(); + self.preview_cache.clone() } } diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index 0120d272f..64ba59c73 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -11,5 +11,6 @@ pub mod mesh_model; pub mod solid_model; pub mod solid_history; pub mod sweep_model; +mod sweep_command_model; pub mod visual_style_model; pub mod object; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index b631f02b6..177360a87 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -212,7 +212,9 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]), - SolidHistoryOperation::Sweep(value) | SolidHistoryOperation::Extrusion(value) => world_point( + SolidHistoryOperation::Sweep(value) => cadkernel::acis::sweep_history_reference_point(value) + .ok().map(glam::DVec3::from_array), + SolidHistoryOperation::Extrusion(value) => world_point( value.base.transform, [ value.reference_point.x, @@ -444,7 +446,7 @@ fn sweep_properties( let (show_history, show_history_editable) = displayed_history_state(object_show_history, show_history_mode); let show_value = if show_history { "Yes" } else { "No" }; - let length = sweep_path_length(value) + let length = cadkernel::acis::sweep_history_path_length(value).ok() .map(crate::entities::common::format_length) .unwrap_or_default(); vec![ @@ -497,7 +499,7 @@ fn sweep_properties( field: PROP_HISTORY, value: PropValue::Choice { selected: if record_history { "Record" } else { "None" }.to_string(), - options: vec!["None".to_string(), "Record".to_string()], + options: vec!["Record".to_string(), "None".to_string()], }, }, Property { @@ -506,7 +508,7 @@ fn sweep_properties( value: if show_history_editable { PropValue::Choice { selected: show_value.to_string(), - options: vec!["No".to_string(), "Yes".to_string()], + options: vec!["Yes".to_string(), "No".to_string()], } } else { PropValue::ReadOnly(show_value.to_string()) @@ -2179,6 +2181,7 @@ fn embedded_entity(value: &EmbeddedEntity) -> Option { EmbeddedEntity::Ellipse(value) => EntityType::Ellipse(value.clone()), EmbeddedEntity::Spline(value) => EntityType::Spline(value.clone()), EmbeddedEntity::LwPolyline(value) => EntityType::LwPolyline(value.clone()), + EmbeddedEntity::Region(value) => EntityType::Region(value.clone()), EmbeddedEntity::Ray(value) => EntityType::Ray(value.clone()), EmbeddedEntity::XLine(value) => EntityType::XLine(value.clone()), EmbeddedEntity::Unknown { .. } => return None, @@ -2194,6 +2197,7 @@ fn into_embedded_entity(value: EntityType) -> Option { EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value), EntityType::Spline(value) => EmbeddedEntity::Spline(value), EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value), + EntityType::Region(value) => EmbeddedEntity::Region(value), EntityType::Ray(value) => EmbeddedEntity::Ray(value), EntityType::XLine(value) => EmbeddedEntity::XLine(value), _ => return None, @@ -2288,21 +2292,27 @@ fn apply_extrusion_profile_grip( true } +fn sweep_placement_matrices(value: &SolidHistorySweep) -> Option<(glam::DMat4, glam::DMat4)> { + let (profile, path) = cadkernel::acis::sweep_history_placements(value).ok()?; + let matrix = |place: cadkernel::brep::Placement| { + glam::DMat4::from_cols( + glam::DVec3::from_array(place.x_axis).extend(0.0), + glam::DVec3::from_array(place.y_axis).extend(0.0), + glam::DVec3::from_array(place.z_axis).extend(0.0), + glam::DVec3::from_array(place.origin).extend(1.0), + ) + }; + Some((matrix(profile), matrix(path))) +} + fn sweep_embedded_grips( value: Option<&EmbeddedEntity>, - base_transform: [f64; 16], - entity_transform: [f64; 16], + transform: glam::DMat4, first_id: usize, ) -> Vec { let Some(entity) = value.and_then(embedded_entity) else { return Vec::new(); }; - let (Some(base), Some(entity_placement)) = - (matrix(base_transform), matrix(entity_transform)) - else { - return Vec::new(); - }; - let transform = base * entity_placement; entity .grips() .into_iter() @@ -2330,8 +2340,7 @@ fn sweep_embedded_grips( fn apply_sweep_embedded_grip( value: &mut Option, - base_transform: [f64; 16], - entity_transform: [f64; 16], + transform: glam::DMat4, index: usize, apply: GripApply, ) -> bool { @@ -2341,12 +2350,7 @@ fn apply_sweep_embedded_grip( let Some(source_grip) = entity.grips().get(index).cloned() else { return false; }; - let (Some(base), Some(entity_placement)) = - (matrix(base_transform), matrix(entity_transform)) - else { - return false; - }; - let inverse = (base * entity_placement).inverse(); + let inverse = transform.inverse(); if !inverse.is_finite() { return false; } @@ -2891,18 +2895,14 @@ pub fn primitive_grips( ); } SolidHistoryOperation::Sweep(value) => { - grips.extend(sweep_embedded_grips( - value.sweep_entity.as_ref(), - value.base.transform, - value.sweep_entity_transform, - GRIP_SWEEP_PROFILE_FIRST, - )); - grips.extend(sweep_embedded_grips( - value.path_entity.as_ref(), - value.base.transform, - value.path_entity_transform, - GRIP_SWEEP_PATH_FIRST, - )); + if let Some((profile, path)) = sweep_placement_matrices(value) { + grips.extend(sweep_embedded_grips( + value.sweep_entity.as_ref(), profile, GRIP_SWEEP_PROFILE_FIRST, + )); + grips.extend(sweep_embedded_grips( + value.path_entity.as_ref(), path, GRIP_SWEEP_PATH_FIRST, + )); + } } SolidHistoryOperation::Loft(value) => grips.extend(loft_section_grips(value)), SolidHistoryOperation::Revolve(value) => { @@ -2962,11 +2962,13 @@ pub fn apply_primitive_grip( } } if let SolidHistoryOperation::Sweep(value) = operation { + let Some((profile, path)) = sweep_placement_matrices(value) else { + return false; + }; if let Some(index) = grip_id.checked_sub(GRIP_SWEEP_PATH_FIRST) { return apply_sweep_embedded_grip( &mut value.path_entity, - value.base.transform, - value.path_entity_transform, + path, index, apply, ); @@ -2977,8 +2979,7 @@ pub fn apply_primitive_grip( { return apply_sweep_embedded_grip( &mut value.sweep_entity, - value.base.transform, - value.sweep_entity_transform, + profile, index, apply, ); diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs new file mode 100644 index 000000000..2361b7c3c --- /dev/null +++ b/src/scene/model/sweep_command_model.rs @@ -0,0 +1,113 @@ +//! SWEEP command data mapped to the shared kernel history reconstruction. + +use acadrust::entities::{Surface, SurfaceData, SurfaceKind, SurfaceSweepOptions}; +use acadrust::objects::{SolidHistoryNodeBase, SolidHistorySweep}; +use acadrust::types::Vector3; +use acadrust::EntityType; +use cadkernel::brep::Body; + +use crate::command::{ExtrudeMode, SweepOptions}; +use super::sweep_model::{embedded_path, embedded_revolve_profile}; + +fn embedded_sweep_profile(entity: &EntityType) -> Option<(acadrust::entities::EmbeddedEntity, [f64; 16])> { + if let EntityType::Region(region) = entity { + Some((acadrust::entities::EmbeddedEntity::Region(region.clone()), glam::DMat4::IDENTITY.to_cols_array())) + } else { + embedded_revolve_profile(entity) + } +} + +pub fn is_sweep_profile(entity: &EntityType) -> bool { + embedded_sweep_profile(entity).is_some_and(|(profile, transform)| { + cadkernel::acis::sweep_profile_geometry(&profile, transform).is_ok() + }) +} + +pub fn is_sweep_path(entity: &EntityType) -> bool { + match embedded_path(entity) { + Some(acadrust::entities::EmbeddedEntity::Spline(value)) => { + value.degree > 0 && (value.control_points.len() > value.degree as usize + || value.fit_points.len() >= 2) + } + Some(_) => crate::entities::curve::entity_curve(entity) + .is_some_and(|curve| curve.curve.length().is_finite() && curve.curve.length() > 1e-9), + None => false, + } +} + +/// All selected profiles use one base point, preserving their relative offsets. +pub fn sweep_selection_options(profiles: &[EntityType], mut options: SweepOptions) -> Option { + if options.base_point.is_some() { + return Some(options); + } + let geometry = profiles.iter().map(|profile| { + let (entity, transform) = embedded_sweep_profile(profile)?; + let (plane, wires, _) = cadkernel::acis::sweep_profile_geometry(&entity, transform).ok()?; + Some((plane, wires)) + }).collect::>>()?; + options.base_point = Some(glam::DVec3::from_array( + cadkernel::brep::sweep_profile_group_base(&geometry)?, + )); + Some(options) +} + +pub fn sweep_record(profile: &EntityType, path: &EntityType, options: SweepOptions) -> Option { + let (sweep_entity, sweep_entity_transform) = embedded_sweep_profile(profile)?; + let (plane, wires, _) = cadkernel::acis::sweep_profile_geometry(&sweep_entity, sweep_entity_transform).ok()?; + let base_point = match options.base_point { + Some(point) => point.to_array(), + None => cadkernel::brep::sweep_profile_base(plane, &wires)?, + }; + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + Some(SolidHistorySweep { + base, + operation_major: 1, + sweep_entity: Some(sweep_entity), + path_entity: Some(embedded_path(path)?), + scale_factor: options.scale, + twist_angle: options.twist_angle, + align_option: u8::from(options.align), + has_align_start: true, + bank: options.bank, + sweep_entity_transform, + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new(base_point[0], base_point[1], base_point[2]), + ..SolidHistorySweep::default() + }) +} + +pub fn swept_with_options(profile: &EntityType, path: &EntityType, mode: ExtrudeMode, options: SweepOptions) -> Option { + let record = sweep_record(profile, path, options)?; + cadkernel::acis::rebuild_sweep_with_mode(&record, mode == ExtrudeMode::Surface).ok() +} + +/// Preserve native construction parameters alongside the sheet's saved B-rep. +pub fn swept_surface_entity(record: &SolidHistorySweep) -> EntityType { + let mut surface = Surface::new(SurfaceKind::Swept); + if let Ok(point) = cadkernel::acis::sweep_history_reference_point(record) { + surface.point_of_reference = Vector3::new(point[0], point[1], point[2]); + } + surface.surface_data = SurfaceData::Swept { + class_version: 0, + sweep_entity: record.sweep_entity.clone(), + path_entity: record.path_entity.clone(), + sweep_transform: glam::DMat4::IDENTITY.to_cols_array(), + path_transform: glam::DMat4::IDENTITY.to_cols_array(), + options: SurfaceSweepOptions { + draft_angle: record.draft_angle, + twist_angle: record.twist_angle, + scale_factor: record.scale_factor, + align_angle: record.align_angle, + sweep_entity_transform: record.sweep_entity_transform, + path_entity_transform: record.path_entity_transform, + sweep_alignment_flags: record.align_option as i16, + align_start: record.has_align_start, + bank: record.bank, + base_point_set: true, + reference_vector: record.reference_point, + ..SurfaceSweepOptions::default() + }, + }; + EntityType::Surface(surface) +} diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 6979b9c2e..8e8a5682f 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -12,6 +12,9 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; use crate::entities::curve::entity_curve; +pub use super::sweep_command_model::{ + is_sweep_path, is_sweep_profile, sweep_record, sweep_selection_options, swept_surface_entity, swept_with_options, +}; /// A drawn profile, as the kernel wants it: the plane it lies in and the /// chain of pieces closing a loop in that plane. @@ -591,18 +594,32 @@ pub fn embedded_path(entity: &EntityType) -> Option { EntityType::Circle(value) => Some(EmbeddedEntity::Circle(value.clone())), EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())), EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())), + EntityType::Polyline2D(value) => { + let mut polyline = LwPolyline::new(); + polyline.elevation = value.elevation; + polyline.normal = value.normal; + polyline.is_closed = value.is_closed(); + polyline.vertices = value.vertices.iter().map(|vertex| { + let mut converted = LwVertex::new(Vector2::new(vertex.location.x, vertex.location.y)); + converted.bulge = vertex.bulge; + converted + }).collect(); + Some(EmbeddedEntity::LwPolyline(polyline)) + } EntityType::Spline(value) => Some(EmbeddedEntity::Spline(value.clone())), - EntityType::Polyline3D(value) if !value.is_closed() && value.vertices.len() >= 2 => { + EntityType::Helix(value) => Some(EmbeddedEntity::Spline(value.spline.clone())), + EntityType::Polyline3D(value) if value.vertices.len() >= 2 => { + let mut points = value.vertices.iter().map(|vertex| vertex.position).collect::>(); + if value.is_closed() && points.first() != points.last() { + points.push(*points.first()?); + } let mut spline = Spline::from_control_points( 1, - value - .vertices - .iter() - .map(|vertex| vertex.position) - .collect(), + points, ); spline.flags.linear = true; spline.flags.planar = false; + spline.flags.closed = value.is_closed(); Some(EmbeddedEntity::Spline(spline)) } _ => None, From 8e3d8511336a2dfe285e6c424ede473628986e81 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:32:05 +0300 Subject: [PATCH 03/16] Fix SWEEP result counter formatting --- src/app/command_driver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 2c8d361f1..1e173a242 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4065,7 +4065,7 @@ impl OpenCADStudio { } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( - "SWEEP: created %{created} object(s); %{failed} source(s) could not be swept.", + "SWEEP: created {created} object(s); {failed} source(s) could not be swept.", created = created_handles.len(), failed = failed ).as_ref()); } else { From 479bf87988118d5cd8c7370fb748854c4c51755d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:03:33 +0300 Subject: [PATCH 04/16] Complete LOFT commands, surfaces, previews and editable history --- Cargo.lock | 6 +- Cargo.toml | 6 +- src/app/command_driver.rs | 100 ++-- src/app/commands/display.rs | 8 +- src/app/commands/styleprops.rs | 33 ++ src/app/model_ops.rs | 9 +- src/app/update/command.rs | 13 +- src/app/update/viewport.rs | 16 +- src/command.rs | 57 +- src/modules/insert/solid3d_cmds.rs | 735 ++++++++++++++++++++++++-- src/scene/mod.rs | 207 ++++++-- src/scene/model/loft_command_model.rs | 95 ++++ src/scene/model/mod.rs | 1 + src/scene/model/solid_history.rs | 311 ++++++++++- src/scene/modify.rs | 69 ++- 15 files changed, 1505 insertions(+), 161 deletions(-) create mode 100644 src/scene/model/loft_command_model.rs diff --git a/Cargo.lock b/Cargo.lock index b647c57cb..6a525ee1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,8 +71,8 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" -version = "0.5.3" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=305e93d4cf8ff70ce8449015edb201c2765fcc04#305e93d4cf8ff70ce8449015edb201c2765fcc04" +version = "0.5.4" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=da65c079396eb6e48d0f5d411eb0f87477479ee2#da65c079396eb6e48d0f5d411eb0f87477479ee2" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=209df414c9c0434af00edabc5529c6c774091f0e#209df414c9c0434af00edabc5529c6c774091f0e" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16#5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 0889b94f3..2bf1ad6a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "305e93d4cf8ff70ce8449015edb201c2765fcc04", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "209df414c9c0434af00edabc5529c6c774091f0e", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "305e93d4cf8ff70ce8449015edb201c2765fcc04" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1e173a242..5327b777a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4082,53 +4082,73 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { handles, color: _ } => { - if let Some(handle) = handles - .iter() - .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) - .copied() - { - self.reject_locked_edit(i, handle); - self.tabs[i].active_cmd = None; - return Task::none(); - } + CmdResult::LoftEntities { sections, guides, path, mode, options, color: _ } => { + use crate::command::LoftSectionSelection; use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_history, sweep_model}; - - let profiles: Vec = handles - .iter() - .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) - .collect(); - let result = sweep_model::loft_history(&profiles) - .and_then(|history| { - cadkernel::acis::rebuild_body(&history) - .ok() - .map(|solid| (solid, history)) - }) - .or_else(|| { - sweep_model::lofted(&profiles).map(|solid| { - let history = solid_history::brep_op(&solid); - (solid, history) - }) - }); - if let Some((solid, history)) = result { - let pending = self.begin_undo(i, "LOFT", 1, true); - let created = self.add_solid_model(empty_solid3d(), solid, history); - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("LOFT: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } + use crate::scene::model::loft_command_model; + let mut sources = sections.iter().flat_map(|section| match section { + LoftSectionSelection::Entity(handle) => vec![*handle], + LoftSectionSelection::Join(handles) => handles.clone(), + LoftSectionSelection::Point(_) => Vec::new(), + }).collect::>(); + sources.sort_unstable_by_key(|handle| handle.value()); + sources.dedup(); + let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let locked = delete_sources && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let available = sources.iter().chain(guides.iter()).copied().chain(path) + .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) + .cloned().map(|entity| (handle, entity))).collect::>(); + let result = if locked { + Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { - self.command_line.push_error(crate::t!("LOFT: select at least two closed profiles.").as_ref()); + loft_command_model::record(§ions, &guides, path, &available, mode, options) + .and_then(|record| cadkernel::acis::rebuild_loft_with_options(&record).map(|body| (body, record))) + }; + match result { + Ok((body, record)) => { + let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); + let dirty_before = self.tabs[i].dirty; + let pending = self.begin_undo(i, "LOFT", 1, true); + let created = if surface { + let handle = self.add_surface_model(loft_command_model::surface_entity(&record), body); + if !handle.is_null() { + self.tabs[i].scene.create_solid_history(handle, + acadrust::objects::SolidHistoryOperation::Loft(record)); + } + handle + } else { + self.add_solid_model(empty_solid3d(), body, + acadrust::objects::SolidHistoryOperation::Loft(record)) + }; + if created.is_null() { + // The creation helper already removed its provisional + // result. Discard its empty undo recording as well. + self.tabs[i].scene.take_undo_recording(); + self.tabs[i].dirty = dirty_before; + self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); + return Task::none(); + } else { + if delete_sources { self.tabs[i].scene.erase_entities(&sources); } + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(created, true); + self.tabs[i].dirty = true; + let message = if surface { + crate::t!("LOFT: surface created.") + } else { crate::t!("LOFT: solid created.") }; + self.command_line.push_output(message.as_ref()); + } + if let Some(pending) = pending { self.commit_undo_delta(i, pending); } + } + Err(error) => { + self.command_line.push_error(&error); + return Task::none(); + } } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + self.refresh_properties(); } CmdResult::SolidEdgeBlend { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 1d8ab193f..cc32048c2 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -832,7 +832,13 @@ impl OpenCADStudio { "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); + let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let selected = self.tabs[i].scene.selected_handles_in_order().into_iter() + .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) + .cloned().map(|entity| (handle, entity))).collect(); + let available = self.tabs[i].scene.document.entities() + .map(|entity| (entity.common().handle, entity.clone())).collect(); + let cmd = LoftCommand::new(color, isolines, crate::command::ExtrudeMode::Solid, selected, available); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index db96ff828..65565df2d 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -830,6 +830,8 @@ impl OpenCADStudio { | "BLIPMODE" | "SPLFRAME" | "DELOBJ" + | "SOLIDHIST" + | "SHOWHIST" | "PLINEGEN" | "PSLTSCALE" | "DISPSILH" @@ -926,6 +928,37 @@ impl OpenCADStudio { "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { + if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { + let current = if name == "SHOWHIST" { + self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2) + } else { + i16::from(self.tabs[i].scene.document.header.record_solid_history) + }; + if let Some(value) = &value { + let maximum = if name == "SHOWHIST" { 2 } else { 1 }; + match value.parse::().ok().filter(|value| (0..=maximum).contains(value)) { + Some(mode) => { + if current != mode { + self.push_undo_snapshot(i, &name); + if name == "SHOWHIST" { + self.tabs[i].scene.document.header.show_solid_history = mode; + self.tabs[i].scene.bump_geometry(); + } else { + self.tabs[i].scene.document.header.record_solid_history = mode != 0; + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + self.command_line.push_output(&format!("{name} = {mode}")); + } + None => self.command_line.push_error(&format!("{name}: expected an integer from 0 to {maximum}.")), + } + } else { + self.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } let frame_kind = crate::scene::frame::kind_for_name(&name); if name == "FRAME" || frame_kind.is_some() { let current = frame_kind.map_or_else( diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index e353de6c8..b1ea73dc7 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -35,8 +35,10 @@ impl super::OpenCADStudio { let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; + let require_complete = matches!(history, SolidHistoryOperation::Loft(_)); self.tabs[i].scene.create_solid_history(handle, history); - if !self.tabs[i].scene.register_solid_model(handle, solid) { + if !self.tabs[i].scene.register_solid_model(handle, solid) + || (require_complete && self.tabs[i].scene.meshes.get(&handle).is_none_or(|mesh| !mesh.complete)) { // A command result is not successful until it has renderable // geometry. Roll the new entity back so DELOBJ cannot consume a // visible source in exchange for an invisible result. @@ -69,7 +71,10 @@ impl super::OpenCADStudio { let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; - if !self.tabs[i].scene.register_solid_model(handle, surface) { + let require_complete = self.tabs[i].scene.document.get_entity(handle).is_some_and(|entity| + matches!(entity, EntityType::Surface(value) if value.kind == acadrust::entities::SurfaceKind::Lofted)); + if !self.tabs[i].scene.register_solid_model(handle, surface) + || (require_complete && self.tabs[i].scene.meshes.get(&handle).is_none_or(|mesh| !mesh.complete)) { self.tabs[i].scene.rollback_new_entities(&[handle]); return Handle::NULL; } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 6f2e10903..1f28e2587 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2001,7 +2001,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if crate::scene::model::solid_history::is_history_choice(field) { + if crate::scene::model::solid_history::is_loft_geometry_choice(field) { + // These choices change the generated body, not just history + // flags. Use the same transactional rebuild as numeric edits. + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + self.tabs[i] + .scene + .apply_solid_history_property(handle, field, &value); + } + } else if crate::scene::model::solid_history::is_history_choice(field) { for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 19141aa5b..27126d77c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3152,6 +3152,19 @@ impl OpenCADStudio { is_added_polyline_vertex(original, current, target.grip_id) .then_some(target.grip_id) }); + // Keep originals available until every history shape has a valid + // final display. A rejected rebuild cancels the entire gesture. + let history_handles: Vec<_> = self.grip_preview_handles.iter().copied() + .filter(|handle| self.tabs[i].scene.document.solid_history_operation(*handle).is_some()) + .collect(); + for handle in history_handles { + if !self.tabs[i].scene.finalize_solid_history(handle) { + self.cancel_active_grip_edit(); + self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref()); + self.refresh_properties(); + return Task::none(); + } + } self.tabs[i].active_grip = None; // Commit the grip drag as one undoable group, then put every // edited entity back into the resident tessellation. @@ -3160,9 +3173,6 @@ impl OpenCADStudio { let history_originals = std::mem::take(&mut self.grip_history_originals); let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty); if !handles.is_empty() { - for &handle in &handles { - let _ = self.tabs[i].scene.finalize_solid_history(handle); - } if !originals.is_empty() { self.push_entity_group_history( i, diff --git a/src/command.rs b/src/command.rs index 3a21fd09a..4a7e983cc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1165,6 +1165,55 @@ impl Default for SweepOptions { } } +/// Ordered cross-sections used by LOFT creation, validation and preview. +#[derive(Clone, Debug, PartialEq)] +pub enum LoftSectionSelection { + Entity(Handle), + Point(DVec3), + /// Connected source edges forming one exact cross-section. + Join(Vec), +} + +/// Construction parameters shared by the LOFT command and its history. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LoftOptions { + /// Ruled, Smooth, First normal, Last normal, Ends normal, All normal, + /// or Use draft angles, respectively. + pub normals: i32, + pub start_draft_angle: f64, + pub end_draft_angle: f64, + pub start_magnitude: f64, + pub end_magnitude: f64, + /// Point-end continuity: G0 (0) or G1 (1). + pub start_continuity: i32, + pub end_continuity: i32, + /// Positive point-end bulge factors; independent of draft magnitudes. + pub start_bulge: f64, + pub end_bulge: f64, + pub closed: bool, + pub periodic: bool, + pub align_direction: bool, +} + +impl Default for LoftOptions { + fn default() -> Self { + Self { + normals: 1, + start_draft_angle: std::f64::consts::FRAC_PI_2, + end_draft_angle: std::f64::consts::FRAC_PI_2, + start_magnitude: 0.0, + end_magnitude: 0.0, + start_continuity: 1, + end_continuity: 1, + start_bulge: 0.5, + end_bulge: 0.5, + closed: false, + periodic: true, + align_direction: true, + } + } +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1484,9 +1533,13 @@ pub enum CmdResult { options: SweepOptions, color: [f32; 4], }, - /// Loft through a series of profile entities. + /// Loft through ordered cross-sections, with optional guides or a path. LoftEntities { - handles: Vec, + sections: Vec, + guides: Vec, + path: Option, + mode: ExtrudeMode, + options: LoftOptions, color: [f32; 4], }, /// Round or bevel the straight edge nearest `pick` on a solid. diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 319ee8a13..6b31dc3e7 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -6,8 +6,8 @@ use acadrust::{entities::Solid3D, EntityType, Handle}; use glam::DVec3; use crate::command::{ - CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, SweepOptions, - WorkingPlane, + CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, LoftOptions, + LoftSectionSelection, SelectionEntity, SweepOptions, WorkingPlane, }; use crate::scene::WireModel; use crate::t; @@ -1606,15 +1606,400 @@ impl CadCommand for SweepCommand { // ── LOFT command ─────────────────────────────────────────────────────────── pub struct LoftCommand { - profiles: Vec, + state: LoftState, + undo: Vec, + available: Vec<(Handle, EntityType)>, + injected_entity: Option, + entity_revision: u64, + preview_key: Option, + preview_cache: Vec, + preview_error: Option, + notice: Option, + isolines: usize, color: [f32; 4], } +#[derive(Clone, Copy, PartialEq, Eq)] +enum LoftStep { + Sections, + Join, + Point, + Mode, + Options, + Guides, + Path, + Settings, + Normals, + StartDraft, + EndDraft, + StartMagnitude, + EndMagnitude, + StartContinuity, + EndContinuity, + StartBulge, + EndBulge, + Closed, + AlignDirection, +} + +#[derive(Clone)] +struct LoftState { + step: LoftStep, + return_step: LoftStep, + sections: Vec, + join: Vec, + guides: Vec, + path: Option, + mode: ExtrudeMode, + options: LoftOptions, +} + +#[derive(Clone, PartialEq)] +struct LoftPreviewKey { + sections: Vec, + guides: Vec, + path: Option, + mode: ExtrudeMode, + options: LoftOptions, + entity_revision: u64, +} + impl LoftCommand { - pub fn new(color: [f32; 4]) -> Self { - Self { - profiles: Vec::new(), + pub fn new( + color: [f32; 4], + isolines: usize, + mode: ExtrudeMode, + seed: Vec<(Handle, EntityType)>, + available: Vec<(Handle, EntityType)>, + ) -> Self { + let mut command = Self { + state: LoftState { + step: LoftStep::Sections, + return_step: LoftStep::Sections, + sections: Vec::new(), + join: Vec::new(), + guides: Vec::new(), + path: None, + mode, + options: LoftOptions::default(), + }, + undo: Vec::new(), + available, + injected_entity: None, + entity_revision: 0, + preview_key: None, + preview_cache: Vec::new(), + preview_error: None, + notice: None, + isolines, color, + }; + // Preserve supplied selection order; a loft is not an unordered set. + for (handle, entity) in seed { + if !handle.is_null() + && !command.contains_section(handle) + && crate::scene::model::loft_command_model::is_section(&entity) + { + command.store_entity(handle, entity); + command.state.sections.push(LoftSectionSelection::Entity(handle)); + } + } + let invalid_point = command.state.sections.iter().enumerate().find_map(|(index, section)| { + (command.is_point_section(section) + && ((index > 0 && index + 1 < command.state.sections.len()) + || (index > 0 && command.is_point_section(&command.state.sections[index - 1])))) + .then_some(index) + }); + if let Some(index) = invalid_point { + command.state.sections.truncate(index); + command.notice = Some(t!("Point cross-sections are allowed only at the first or last end, with a curve between point ends.").into_owned()); + } else if command.state.sections.len() >= 2 { + command.state.step = LoftStep::Options; + } + command + } + + fn store_entity(&mut self, handle: Handle, entity: EntityType) { + if let Some((_, stored)) = self.available.iter_mut().find(|(id, _)| *id == handle) { + *stored = entity; + } else { + self.available.push((handle, entity)); + } + self.entity_revision = self.entity_revision.wrapping_add(1); + } + + fn contains_section(&self, handle: Handle) -> bool { + self.state.sections.iter().any(|section| match section { + LoftSectionSelection::Entity(id) => *id == handle, + LoftSectionSelection::Join(handles) => handles.contains(&handle), + LoftSectionSelection::Point(_) => false, + }) + } + + fn is_point_section(&self, section: &LoftSectionSelection) -> bool { + match section { + LoftSectionSelection::Point(_) => true, + LoftSectionSelection::Entity(handle) => self.available.iter().any(|(id, entity)| { + id == handle && matches!(entity, EntityType::Point(_)) + }), + LoftSectionSelection::Join(_) => false, + } + } + + fn point_ends(&self) -> (bool, bool) { + ( + self.state.sections.first().is_some_and(|section| self.is_point_section(section)), + self.state.sections.last().is_some_and(|section| self.is_point_section(section)), + ) + } + + fn point_setting_next(&self, step: LoftStep) -> LoftStep { + match step { + LoftStep::StartContinuity if self.point_ends().1 => LoftStep::EndContinuity, + LoftStep::StartBulge if self.point_ends().1 => LoftStep::EndBulge, + _ => LoftStep::Options, + } + } + + fn remember(&mut self) { + self.undo.push(self.state.clone()); + self.notice = None; + } + + fn enter_step(&mut self, step: LoftStep) { + self.remember(); + self.state.step = step; + } + + fn invalid(&mut self, message: String) -> CmdResult { + self.notice = Some(message); + CmdResult::NeedPoint + } + + fn can_close(&self) -> bool { + self.state.sections.len() >= 3 + && !self.state.sections.iter().any(|section| self.is_point_section(section)) + } + + fn key(&self) -> LoftPreviewKey { + LoftPreviewKey { + sections: self.state.sections.clone(), + guides: self.state.guides.clone(), + path: self.state.path, + mode: self.state.mode, + options: self.state.options, + entity_revision: self.entity_revision, + } + } + + fn cached_preview(&mut self, key: LoftPreviewKey) -> Vec { + if self.preview_key.as_ref() == Some(&key) { + return self.preview_cache.clone(); + } + self.preview_cache.clear(); + self.preview_error = None; + if key.sections.len() >= 2 { + match crate::scene::model::loft_command_model::build_body( + &key.sections, + &key.guides, + key.path, + &self.available, + key.mode, + key.options, + ) { + Ok(body) => { + // Only edges/isolines: do not triangulate faces on mouse movement. + self.preview_cache = preview_body_wires(&body, self.color, self.isolines); + } + Err(error) => self.preview_error = Some(error), + } + } + self.preview_key = Some(key); + self.preview_cache.clone() + } + + fn finish(&mut self) -> CmdResult { + if self.state.sections.len() < 2 { + return self.invalid(t!("LOFT requires at least two cross-sections.").into_owned()); + } + if self.state.sections.iter().enumerate().any(|(index, section)| { + self.is_point_section(section) + && ((index > 0 && index + 1 < self.state.sections.len()) + || (index > 0 && self.is_point_section(&self.state.sections[index - 1]))) + }) { + return self.invalid(t!("Point cross-sections are allowed only at the first or last end, with a curve between point ends.").into_owned()); + } + if self.state.options.closed && !self.can_close() { + return self.invalid(t!("A closed loft requires at least three curve cross-sections and no point ends.").into_owned()); + } + let key = self.key(); + self.cached_preview(key); + if let Some(error) = self.preview_error.clone() { + return self.invalid(error); + } + CmdResult::LoftEntities { + sections: self.state.sections.clone(), + guides: self.state.guides.clone(), + path: self.state.path, + mode: self.state.mode, + options: self.state.options, + color: self.color, + } + } + + fn undo_selection(&mut self) -> CmdResult { + if let Some(state) = self.undo.pop() { + self.state = state; + } else if !self.state.sections.is_empty() { + self.state.sections.pop(); + self.state.step = LoftStep::Sections; + self.state.options.closed = false; + } + self.notice = None; + CmdResult::NeedPoint + } + + fn add_section(&mut self, handle: Handle) -> CmdResult { + if self.state.sections.len() >= 2 && self.point_ends().1 { + return self.invalid(t!("A point must remain the last cross-section; use Undo to change the end.").into_owned()); + } + if self.contains_section(handle) { + return self.invalid(t!("That cross-section is already selected.").into_owned()); + } + if self.available.iter().find(|(id, _)| *id == handle) + .is_none_or(|(_, entity)| !crate::scene::model::loft_command_model::is_section(entity)) + { + return self.invalid(t!("Select a supported planar curve or region cross-section.").into_owned()); + } + let section = LoftSectionSelection::Entity(handle); + let point = self.is_point_section(§ion); + if point && self.point_ends().1 { + return self.invalid(t!("Add a curve cross-section between point ends.").into_owned()); + } + self.remember(); + self.state.sections.push(section); + if point { + self.state.options.closed = false; + if self.state.sections.len() >= 2 { + self.state.step = LoftStep::Options; + } + } + CmdResult::NeedPoint + } + + fn set_numeric_setting(&mut self, text: &str) -> bool { + let angle = matches!(self.state.step, LoftStep::StartDraft | LoftStep::EndDraft); + let bulge = matches!(self.state.step, LoftStep::StartBulge | LoftStep::EndBulge); + let number = if angle { + crate::entities::common::parse_angle(text) + } else { + text.trim().replace(',', ".").parse::().ok() + }; + let Some(number) = number.filter(|number| number.is_finite()) else { + return false; + }; + if (angle && !(0.0..=std::f64::consts::PI).contains(&number)) + || (!angle && number < 0.0) + || (bulge && number < 0.0) + { + return false; + } + self.remember(); + self.state.step = match self.state.step { + LoftStep::StartDraft => { + self.state.options.start_draft_angle = number; + LoftStep::EndDraft + } + LoftStep::EndDraft => { + self.state.options.end_draft_angle = number; + LoftStep::Settings + } + LoftStep::StartMagnitude => { + self.state.options.start_magnitude = number; + LoftStep::EndMagnitude + } + LoftStep::EndMagnitude => { + self.state.options.end_magnitude = number; + LoftStep::Settings + } + LoftStep::StartBulge => { + self.state.options.start_bulge = number; + self.point_setting_next(LoftStep::StartBulge) + } + LoftStep::EndBulge => { + self.state.options.end_bulge = number; + LoftStep::Options + } + _ => return false, + }; + true + } + + fn keyword(text: &str, choices: &[&str]) -> Option { + let keyword = text.trim().trim_start_matches('_').replace([' ', '-'], "").to_ascii_uppercase(); + let keyword = match keyword.as_str() { + "CROSSSECTIONSONLY" => "CROSSSECTIONS", + "BULGEMAGNITUDE" => "BULGE", + _ => keyword.as_str(), + }; + if keyword.is_empty() { + return None; + } + if let Some(index) = choices.iter().position(|choice| *choice == keyword) { + return Some(index); + } + let mut matches = choices.iter().enumerate().filter(|(_, choice)| choice.starts_with(keyword)); + let index = matches.next()?.0; + matches.next().is_none().then_some(index) + } + + fn option_prompt(&self) -> String { + match self.state.step { + LoftStep::Sections => format!( + "{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode/Undo] (Enter to finish)"), + self.state.sections.len(), + ), + LoftStep::Join => format!( + "{} ({}):", t!("LOFT Select connected edges for one cross-section or [Undo] (Enter to finish)"), + self.state.join.len(), + ), + LoftStep::Point => t!("LOFT Specify point end:").into_owned(), + LoftStep::Mode => format!("{} <{}>:", t!("LOFT Creation mode [Solid/Surface]"), + if self.state.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" }), + LoftStep::Options => { + let (start, end) = self.point_ends(); + if start || end { + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude/Mode/Undo] :").into_owned() + } else { + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Mode/Undo] :").into_owned() + } + } + LoftStep::Guides => format!("{} ({}):", t!("LOFT Select guide curves or [Undo] (Enter to finish)"), self.state.guides.len()), + LoftStep::Path => t!("LOFT Select path or [Undo]:").into_owned(), + LoftStep::Settings => { + let mut choices = vec![t!("Normals").into_owned()]; + if self.state.options.normals == 6 { + choices.push(t!("Draft angles").into_owned()); + choices.push(t!("Magnitudes").into_owned()); + } + if self.can_close() { + choices.push(t!("Closed").into_owned()); + } + choices.push(t!("Align direction").into_owned()); + choices.push(t!("Done").into_owned()); + format!("{} [{}]:", t!("LOFT Settings"), choices.join("/")) + } + LoftStep::Normals => t!("LOFT Surface normals [Ruled/Smooth/First normal/Last normal/Ends normal/All normal/Use draft angles]:").into_owned(), + LoftStep::StartDraft => format!("{} <{}>:", t!("LOFT Start draft angle"), crate::entities::common::format_angle(self.state.options.start_draft_angle)), + LoftStep::EndDraft => format!("{} <{}>:", t!("LOFT End draft angle"), crate::entities::common::format_angle(self.state.options.end_draft_angle)), + LoftStep::StartMagnitude => format!("{} <{}>:", t!("LOFT Start magnitude"), self.state.options.start_magnitude), + LoftStep::EndMagnitude => format!("{} <{}>:", t!("LOFT End magnitude"), self.state.options.end_magnitude), + LoftStep::StartContinuity => format!("{} :", t!("LOFT Start point continuity [G0/G1]"), self.state.options.start_continuity), + LoftStep::EndContinuity => format!("{} :", t!("LOFT End point continuity [G0/G1]"), self.state.options.end_continuity), + LoftStep::StartBulge => format!("{} <{:.4}>:", t!("LOFT Start point bulge magnitude"), self.state.options.start_bulge), + LoftStep::EndBulge => format!("{} <{:.4}>:", t!("LOFT End point bulge magnitude"), self.state.options.end_bulge), + LoftStep::Closed => format!("{} <{}>:", t!("LOFT Closed [Yes/No]"), if self.state.options.closed { "Yes" } else { "No" }), + LoftStep::AlignDirection => format!("{} <{}>:", t!("LOFT Align cross-section directions [Yes/No]"), if self.state.options.align_direction { "Yes" } else { "No" }), } } } @@ -1624,47 +2009,331 @@ impl CadCommand for LoftCommand { "LOFT" } fn prompt(&self) -> String { - if self.profiles.is_empty() { - t!("LOFT Select first cross-section:").into_owned() - } else { - t!( - "LOFT Select next cross-section (%{count} selected, Enter to finish):", - count = self.profiles.len() - ) - .into_owned() + let prompt = self.option_prompt(); + match &self.notice { + Some(notice) => format!("{notice}\n{prompt}"), + None => prompt, } } + fn options(&self) -> Vec { + let mut options = match self.state.step { + LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + LoftStep::Join | LoftStep::Guides => vec![CmdOption::enter("Done")], + LoftStep::Mode => vec![CmdOption::new("Solid", "SOLID"), CmdOption::new("Surface", "SURFACE")], + LoftStep::Options => { + let mut choices = vec![CmdOption::new("Guides", "GUIDES"), CmdOption::new("Path", "PATH"), CmdOption::new("Cross sections only", "CROSSSECTIONS"), CmdOption::new("Settings", "SETTINGS")]; + let (start, end) = self.point_ends(); + if start || end { + choices.push(CmdOption::new("Continuity", "CONTINUITY")); + choices.push(CmdOption::new("Bulge magnitude", "BULGE")); + } + choices.push(CmdOption::new("Mode", "MODE")); + choices + } + LoftStep::Settings => { + let mut choices = vec![CmdOption::new("Normals", "NORMALS")]; + if self.state.options.normals == 6 { + choices.push(CmdOption::new("Draft angles", "DRAFTANGLES")); + choices.push(CmdOption::new("Magnitudes", "MAGNITUDES")); + } + if self.can_close() { + choices.push(CmdOption::new("Closed", "CLOSED")); + } + choices.push(CmdOption::new("Align direction", "ALIGNDIRECTION")); + choices.push(CmdOption::enter("Done")); + choices + } + LoftStep::Normals => vec![ + CmdOption::new("Ruled", "RULED"), CmdOption::new("Smooth", "SMOOTH"), + CmdOption::new("First normal", "FIRSTNORMAL"), CmdOption::new("Last normal", "LASTNORMAL"), + CmdOption::new("Ends normal", "ENDSNORMAL"), CmdOption::new("All normal", "ALLNORMAL"), + CmdOption::new("Use draft angles", "USEDRAFTANGLES"), + ], + LoftStep::Closed | LoftStep::AlignDirection => vec![CmdOption::new("Yes", "YES"), CmdOption::new("No", "NO")], + LoftStep::StartContinuity | LoftStep::EndContinuity => vec![CmdOption::new("G0", "G0"), CmdOption::new("G1", "G1")], + _ => Vec::new(), + }; + options.push(CmdOption::new("Undo", "UNDO")); + options + } fn needs_entity_pick(&self) -> bool { - true + matches!(self.state.step, LoftStep::Sections | LoftStep::Join | LoftStep::Guides | LoftStep::Path) } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + fn inject_before_entity_pick(&self) -> bool { + self.needs_entity_pick() + } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.injected_entity = Some(entity); + } + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if !self.needs_entity_pick() || handle.is_null() { + self.injected_entity = None; return CmdResult::NeedPoint; } - // Avoid duplicate picks. - if !self.profiles.contains(&handle) { - self.profiles.push(handle); + if let Some(entity) = self.injected_entity.take() { + self.store_entity(handle, entity); + } + if self.state.step == LoftStep::Sections { + return self.add_section(handle); + } + if self.contains_section(handle) { + return self.invalid(t!("A cross-section cannot also be a guide, path, or joined edge.").into_owned()); + } + if self.available.iter().find(|(id, _)| *id == handle) + .is_none_or(|(_, entity)| !crate::scene::model::loft_command_model::is_guide_or_path(entity)) + { + return self.invalid(t!("Select a supported guide or path curve.").into_owned()); + } + match self.state.step { + LoftStep::Join => { + if self.state.join.contains(&handle) { + return self.invalid(t!("That edge is already selected.").into_owned()); + } + self.remember(); + self.state.join.push(handle); + } + LoftStep::Guides => { + if self.state.guides.contains(&handle) { + return self.invalid(t!("That guide is already selected.").into_owned()); + } + self.remember(); + self.state.guides.push(handle); + } + LoftStep::Path => { + self.remember(); + self.state.path = Some(handle); + self.state.guides.clear(); + return self.finish(); + } + _ => {} } - CmdResult::NeedPoint - } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } fn wants_text_input(&self) -> bool { - self.profiles.len() >= 2 + true } - fn on_text_input(&mut self, _text: &str) -> Option { - None + fn point_step_accepts_keywords(&self) -> bool { + self.state.step == LoftStep::Point } - fn on_enter(&mut self) -> CmdResult { - if self.profiles.len() < 2 { - CmdResult::Cancel - } else { - CmdResult::LoftEntities { - handles: self.profiles.clone(), - color: self.color, + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.state.step != LoftStep::Point || !point.is_finite() { + return CmdResult::NeedPoint; + } + if self.point_ends().1 { + return self.invalid(t!("Add a curve cross-section between point ends.").into_owned()); + } + self.remember(); + self.state.sections.push(LoftSectionSelection::Point(point)); + self.state.options.closed = false; + self.state.step = if self.state.sections.len() >= 2 { LoftStep::Options } else { LoftStep::Sections }; + CmdResult::NeedPoint + } + fn on_text_input(&mut self, text: &str) -> Option { + if text.trim().is_empty() { + return Some(self.on_enter()); + } + if Self::keyword(text, &["UNDO"]).is_some() { + return Some(self.undo_selection()); + } + match self.state.step { + LoftStep::Sections => match Self::keyword(text, &["POINT", "JOIN", "MODE"]) { + Some(0) => { + if self.point_ends().1 { + return Some(self.invalid(t!("Select a curve cross-section before another point end.").into_owned())); + } + self.enter_step(LoftStep::Point); + } + Some(1) => { + if self.state.sections.len() >= 2 && self.point_ends().1 { + return Some(self.invalid(t!("A point must remain the last cross-section; use Undo to change the end.").into_owned())); + } + self.enter_step(LoftStep::Join); + self.state.join.clear(); + } + Some(2) => { + self.enter_step(LoftStep::Mode); + self.state.return_step = LoftStep::Sections; + } + _ => return Some(self.invalid(t!("Select a cross-section or enter Point, Join, Mode, or Undo.").into_owned())), + }, + LoftStep::Mode => match Self::keyword(text, &["SOLID", "SURFACE"]) { + Some(mode) => { + self.remember(); + self.state.mode = if mode == 0 { ExtrudeMode::Solid } else { ExtrudeMode::Surface }; + self.state.step = self.state.return_step; + } + _ => return Some(self.invalid(t!("Enter Solid or Surface.").into_owned())), + }, + LoftStep::Options => match Self::keyword(text, &["GUIDES", "PATH", "CROSSSECTIONS", "SETTINGS", "MODE", "CONTINUITY", "BULGE"]) { + Some(0) => { + self.enter_step(LoftStep::Guides); + self.state.path = None; + } + Some(1) => { + self.enter_step(LoftStep::Path); + self.state.guides.clear(); + } + Some(2) => { + self.remember(); + self.state.guides.clear(); + self.state.path = None; + return Some(self.finish()); + } + Some(3) => self.enter_step(LoftStep::Settings), + Some(4) => { + self.enter_step(LoftStep::Mode); + self.state.return_step = LoftStep::Options; + } + Some(5) | Some(6) if !self.point_ends().0 && !self.point_ends().1 => { + return Some(self.invalid(t!("Continuity and bulge magnitude require a point cross-section at an end.").into_owned())); + } + Some(5) => self.enter_step(if self.point_ends().0 { LoftStep::StartContinuity } else { LoftStep::EndContinuity }), + Some(6) => self.enter_step(if self.point_ends().0 { LoftStep::StartBulge } else { LoftStep::EndBulge }), + _ => return Some(self.invalid(t!("Enter one of the listed loft options.").into_owned())), + }, + LoftStep::Settings => match Self::keyword(text, &["NORMALS", "DRAFTANGLES", "MAGNITUDES", "CLOSED", "ALIGNDIRECTION", "DONE"]) { + Some(0) => self.enter_step(LoftStep::Normals), + Some(1) | Some(2) if self.state.options.normals != 6 => { + return Some(self.invalid(t!("Choose Use draft angles under Normals before editing draft angles or magnitudes.").into_owned())); + } + Some(1) => self.enter_step(LoftStep::StartDraft), + Some(2) => self.enter_step(LoftStep::StartMagnitude), + Some(3) if self.can_close() => self.enter_step(LoftStep::Closed), + Some(3) => return Some(self.invalid(t!("Closed is available for at least three curve cross-sections without point ends.").into_owned())), + Some(4) => self.enter_step(LoftStep::AlignDirection), + Some(5) => self.enter_step(LoftStep::Options), + _ => return Some(self.invalid(t!("Enter a listed loft setting.").into_owned())), + }, + LoftStep::Normals => { + let selected = Self::keyword(text, &["RULED", "SMOOTH", "FIRSTNORMAL", "LASTNORMAL", "ENDSNORMAL", "ALLNORMAL", "USEDRAFTANGLES"]) + .or_else(|| text.trim().parse::().ok().filter(|value| *value <= 6)); + let Some(selected) = selected else { + return Some(self.invalid(t!("Select one of the seven surface normal options.").into_owned())); + }; + self.remember(); + self.state.options.normals = selected as i32; + self.state.step = LoftStep::Settings; + } + LoftStep::StartDraft | LoftStep::EndDraft | LoftStep::StartMagnitude | LoftStep::EndMagnitude => { + if !self.set_numeric_setting(text) { + return Some(self.invalid(t!("Enter a finite draft angle from 0 to 180 degrees or a non-negative magnitude.").into_owned())); + } + } + LoftStep::StartContinuity | LoftStep::EndContinuity => { + let continuity = match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() { + "G0" => 0, + "G1" => 1, + _ => return Some(self.invalid(t!("Enter G0 or G1.").into_owned())), + }; + let step = self.state.step; + self.remember(); + if step == LoftStep::StartContinuity { + self.state.options.start_continuity = continuity; + } else { + self.state.options.end_continuity = continuity; + } + self.state.step = self.point_setting_next(step); + } + LoftStep::StartBulge | LoftStep::EndBulge => { + if !self.set_numeric_setting(text) { + return Some(self.invalid(t!("Enter a finite bulge magnitude greater than zero.").into_owned())); + } + } + LoftStep::Closed | LoftStep::AlignDirection => { + let Some(selected) = Self::keyword(text, &["YES", "NO"]) else { + return Some(self.invalid(t!("Enter Yes or No.").into_owned())); + }; + if self.state.step == LoftStep::Closed && selected == 0 && !self.can_close() { + return Some(self.invalid(t!("A closed loft requires at least three curve cross-sections and no point ends.").into_owned())); + } + self.remember(); + if self.state.step == LoftStep::Closed { + self.state.options.closed = selected == 0; + } else { + self.state.options.align_direction = selected == 0; + } + self.state.step = LoftStep::Settings; + } + LoftStep::Point => return None, + LoftStep::Join | LoftStep::Guides | LoftStep::Path => { + return Some(self.invalid(t!("Select a curve or use Undo.").into_owned())); } } + Some(CmdResult::NeedPoint) + } + fn on_enter(&mut self) -> CmdResult { + match self.state.step { + LoftStep::Sections if self.state.sections.len() >= 2 => self.enter_step(LoftStep::Options), + LoftStep::Sections => return self.invalid(t!("LOFT requires at least two cross-sections; select another section or a point end.").into_owned()), + LoftStep::Join if !self.state.join.is_empty() => { + self.remember(); + self.state.sections.push(LoftSectionSelection::Join(std::mem::take(&mut self.state.join))); + self.state.step = LoftStep::Sections; + } + LoftStep::Join => return self.invalid(t!("Select connected edges for the cross-section.").into_owned()), + LoftStep::Options => return self.finish(), + LoftStep::Guides if !self.state.guides.is_empty() => return self.finish(), + LoftStep::Guides => return self.invalid(t!("Select at least one guide curve.").into_owned()), + LoftStep::Mode => self.enter_step(self.state.return_step), + LoftStep::Settings => self.enter_step(LoftStep::Options), + LoftStep::Normals | LoftStep::Closed | LoftStep::AlignDirection | LoftStep::EndDraft | LoftStep::EndMagnitude => self.enter_step(LoftStep::Settings), + LoftStep::StartDraft => self.enter_step(LoftStep::EndDraft), + LoftStep::StartMagnitude => self.enter_step(LoftStep::EndMagnitude), + LoftStep::StartContinuity | LoftStep::EndContinuity | LoftStep::StartBulge | LoftStep::EndBulge => self.enter_step(self.point_setting_next(self.state.step)), + LoftStep::Path | LoftStep::Point => return self.invalid(t!("Specify the requested path or point, or use Undo.").into_owned()), + } + CmdResult::NeedPoint + } + fn on_undo_step(&mut self) -> Option { + Some(self.undo_selection()) + } + fn wants_hover_entity(&self, handle: Handle) -> bool { + self.needs_entity_pick() && !handle.is_null() + && !self.available.iter().any(|(id, _)| *id == handle) + } + fn inject_hover_entity(&mut self, handle: Handle, entity: EntityType) { + self.store_entity(handle, entity); + } + fn on_hover_entity(&mut self, handle: Handle, _point: DVec3) -> Vec { + let mut key = self.key(); + if !handle.is_null() && !self.contains_section(handle) { + if let Some((_, entity)) = self.available.iter().find(|(id, _)| *id == handle) { + match self.state.step { + LoftStep::Sections if crate::scene::model::loft_command_model::is_section(entity) => { + let section = LoftSectionSelection::Entity(handle); + let point = self.is_point_section(§ion); + if !(self.point_ends().1 && (point || self.state.sections.len() >= 2)) { + key.sections.push(section); + if point { + key.options.closed = false; + } + } + } + LoftStep::Guides if !key.guides.contains(&handle) + && crate::scene::model::loft_command_model::is_guide_or_path(entity) => key.guides.push(handle), + LoftStep::Path if crate::scene::model::loft_command_model::is_guide_or_path(entity) => { + key.path = Some(handle); + key.guides.clear(); + } + _ => {} + } + } + } + self.cached_preview(key) + } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let mut key = self.key(); + if self.state.step == LoftStep::Point && point.is_finite() + && !key.sections.is_empty() + && !self.point_ends().1 + { + key.sections.push(LoftSectionSelection::Point(point)); + key.options.closed = false; + } + self.cached_preview(key) } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e3bcb3b45..bbb5d3226 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -54,10 +54,84 @@ pub(crate) use boundary::{ // other tessellation code); re-exported here so this root and sibling topic // modules (each does `use super::*`) keep referencing them unqualified. pub(crate) use convert::tess::{ - entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity, + entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text, }; +/// Keep construction-history curves in the same per-entity tessellation memo +/// as their parent, so navigation never rebuilds them on every frame. +#[allow(clippy::too_many_arguments)] +pub(crate) fn tessellate_entity( + document: &CadDocument, + selected: &HashSet, + active_viewport: Option, + bg_color: [f32; 4], + anno_scale: f32, + annotation_scale_handle: Option, + entity: &EntityType, + block_cache: Option<&cache::block_cache::BlockCache>, + view_aabb: Option<[f32; 4]>, + world_per_pixel: Option, + paper_space: bool, +) -> Vec { + let mut wires = convert::tess::tessellate_entity( + document, selected, active_viewport, bg_color, anno_scale, + annotation_scale_handle, entity, block_cache, view_aabb, + world_per_pixel, paper_space, + ); + if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { + let handle = entity.common().handle; + for source in model::solid_history::loft_visible_history_entities(document, handle) { + // Call the raw converter, not this wrapper: sources must not expand + // the parent's history again through their shared selection handle. + let mut construction = if let EntityType::Region(region) = &source { + // Regions normally render through the mesh layer, so the raw + // wire converter emits nothing. Decode every exact boundary, + // including holes, then sample only for this cached display. + let mut boundaries = Vec::new(); + if let Ok(cadkernel::brep::LoftSection::Profile { plane, wires, .. }) = + cadkernel::acis::loft_section_geometry(&[ + acadrust::entities::EmbeddedEntity::Region(region.clone()), + ]) + { + let (color, _, _, line_weight_px, _) = + view::render::render_style_for_viewport(document, &source, active_viewport); + for boundary in wires { + for curve in boundary { + let points = curve.tessellate(64.0 / std::f64::consts::TAU) + .into_iter().map(|point| plane.point_at(point)); + let (points, points_low) = convert::tessellate::points_to_ds(points); + let mut wire = WireModel::solid( + handle.value().to_string(), points, color, selected.contains(&handle), + ); + wire.points_low = points_low; + wire.line_weight_px = line_weight_px; + boundaries.push(wire); + } + } + } + boundaries + } else { + convert::tess::tessellate_entity( + document, selected, active_viewport, bg_color, anno_scale, + annotation_scale_handle, &source, block_cache, view_aabb, + world_per_pixel, paper_space, + ) + }; + for wire in &mut construction { + wire.name = handle.value().to_string(); + if !wire.selected { + wire.color = [1.0, 0.25, 0.25, wire.color[3]]; + } + wire.fill_tris.clear(); + wire.fill_tris_low.clear(); + } + wires.extend(construction); + } + } + wires +} + /// Result of `Scene::entity_index()`. The wire path queries `tree` for /// view-rect candidates and also always emits `unbounded_handles` /// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel). @@ -2731,12 +2805,12 @@ impl Scene { }) } - /// Cache and display a Model-tab kernel solid. - pub fn register_solid_model( - &mut self, + /// Prepare display geometry without changing the entity or resident caches. + pub(crate) fn prepare_solid_model_display( + &self, handle: Handle, - solid: cadkernel::brep::Body, - ) -> bool { + solid: &cadkernel::brep::Body, + ) -> Option<(MeshLodSet, Vec, [f64; 3])> { let color = self .document .get_entity(handle) @@ -2746,67 +2820,88 @@ impl Scene { let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); let isolines = self.document.header.isolines.max(0) as usize; - let displayed = if let Some((mut set, wires, center)) = + let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( - &solid, + solid, color, facet_resolution, chordal_deflection, isolines, + )?; + let name = handle.value().to_string(); + for mesh in &mut set.lods { + mesh.name = name.clone(); + } + if let Some(entity) = self.document.get_entity(handle) { + crate::scene::model::material_model::resolve_material_with_base( + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), ) - { - if let Some(entity) = self.document.get_entity_mut(handle) { - let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); - match entity { - EntityType::Solid3D(entity) => { - entity.point_of_reference = reference; - entity.wires = wires; - entity.silhouettes.clear(); - } - EntityType::Surface(entity) => { - entity.point_of_reference = reference; - entity.wires = wires; - entity.silhouettes.clear(); - } - _ => {} + .apply_to_with_face_overrides( + &mut set, + &self.document, + self.material_base_dir.as_deref(), + ); + crate::scene::model::visual_style_model::apply_mesh_visual_style( + &mut set, + &self.document, + entity, + ); + } + Some((set, wires, center)) + } + + /// Commit a prepared display without tessellating the body a second time. + pub(crate) fn register_prepared_solid_model( + &mut self, + handle: Handle, + solid: cadkernel::brep::Body, + display: (MeshLodSet, Vec, [f64; 3]), + ) -> bool { + let (set, wires, center) = display; + if let Some(entity) = self.document.get_entity_mut(handle) { + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match entity { + EntityType::Solid3D(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); } + EntityType::Surface(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); + } + _ => {} } - let name = handle.value().to_string(); - for mesh in &mut set.lods { - mesh.name = name.clone(); - } - if let Some(entity) = self.document.get_entity(handle) { - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ) - .apply_to_with_face_overrides( - &mut set, - &self.document, - self.material_base_dir.as_deref(), - ); - crate::scene::model::visual_style_model::apply_mesh_visual_style( - &mut set, - &self.document, - entity, - ); - } - self.meshes.insert(handle, set); - true - } else { - self.meshes.remove(&handle); - false - }; + } + self.meshes.insert(handle, set); self.solid_models.insert(handle, solid); self.sync_solid_reference_point(handle); - // Only this solid's mesh changed — report just its handle so the mesh / - // wire caches patch it in rather than rebuilding the whole drawing (and - // so a bulk register loop stays O(n), not O(n²)). self.bump_entities(&[(handle, ChangeKind::Modified)]); - displayed + true + } + + /// Cache and display a Model-tab kernel solid. A failed preparation leaves + /// the previous entity, body and mesh untouched. + pub fn register_solid_model( + &mut self, + handle: Handle, + solid: cadkernel::brep::Body, + ) -> bool { + let Some(display) = self.prepare_solid_model_display(handle, &solid) else { + return false; + }; + if !display.0.complete && matches!( + self.document.solid_history_operation(handle), + Some(acadrust::objects::SolidHistoryOperation::Loft(_)) + ) { + return false; + } + self.register_prepared_solid_model(handle, solid, display) } /// `BACKGROUND` change picks up the new `adapt_to_bg` result without diff --git a/src/scene/model/loft_command_model.rs b/src/scene/model/loft_command_model.rs new file mode 100644 index 000000000..1378ed0d2 --- /dev/null +++ b/src/scene/model/loft_command_model.rs @@ -0,0 +1,95 @@ +//! LOFT command/history integration. Geometry and validation live in the kernel. + +use acadrust::entities::{EmbeddedEntity, Point, Surface, SurfaceData, SurfaceKind}; +use acadrust::objects::{SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase}; +use acadrust::{EntityType, Handle}; +use cadkernel::brep::Body; +use crate::command::{ExtrudeMode, LoftOptions, LoftSectionSelection}; +use super::sweep_model::embedded_path; + +fn embedded_section(entity: &EntityType) -> Option { + match entity { + EntityType::Region(region) => Some(EmbeddedEntity::Region(region.clone())), + EntityType::Point(point) => Some(EmbeddedEntity::Point(point.clone())), + _ => embedded_path(entity), + } +} + +pub fn is_section(entity: &EntityType) -> bool { + embedded_section(entity).is_some_and(|entity| + cadkernel::acis::loft_section_geometry(&[entity]).is_ok()) +} + +pub fn is_guide_or_path(entity: &EntityType) -> bool { + embedded_path(entity).is_some_and(|entity| cadkernel::acis::loft_path_geometry(&entity).is_ok()) +} + +pub fn record( + sections: &[LoftSectionSelection], guides: &[Handle], path: Option, + available: &[(Handle, EntityType)], mode: ExtrudeMode, options: LoftOptions, +) -> Result { + let find = |handle| available.iter().find(|(candidate, _)| *candidate == handle) + .map(|(_, entity)| entity).ok_or_else(|| "A selected loft source no longer exists.".to_string()); + let mut cross_sections = Vec::new(); + let mut section_counts = Vec::new(); + let mut surface = mode == ExtrudeMode::Surface; + for section in sections { + let members = match section { + LoftSectionSelection::Entity(handle) => vec![embedded_section(find(*handle)?) + .ok_or("Unsupported loft section.")?], + LoftSectionSelection::Point(point) => vec![EmbeddedEntity::Point(Point::from_coords(point.x, point.y, point.z))], + LoftSectionSelection::Join(handles) => handles.iter().map(|handle| { + embedded_section(find(*handle)?).ok_or_else(|| "Unsupported joined loft edge.".to_string()) + }).collect::, _>>()?, + }; + if let cadkernel::brep::LoftSection::Profile { closed, .. } = cadkernel::acis::loft_section_geometry(&members)? { + surface |= !closed; + } + section_counts.push(members.len()); + cross_sections.extend(members); + } + let guides = guides.iter().map(|handle| embedded_path(find(*handle)?) + .ok_or_else(|| "Unsupported loft guide.".to_string())).collect::, _>>()?; + let path_entity = path.map(|handle| embedded_path(find(handle)?) + .ok_or_else(|| "Unsupported loft path.".to_string())).transpose()?; + Ok(SolidHistoryLoft { + base: SolidHistoryNodeBase::new(1), operation_major: 1, + cross_sections, guides, + parameters: Some(SolidHistoryLoftParameters { + path_entity, normals: options.normals, + start_draft_angle: options.start_draft_angle, end_draft_angle: options.end_draft_angle, + start_magnitude: options.start_magnitude, end_magnitude: options.end_magnitude, + start_continuity: options.start_continuity, end_continuity: options.end_continuity, + start_bulge: options.start_bulge, end_bulge: options.end_bulge, + closed: options.closed, periodic: options.periodic, surface, align_direction: options.align_direction, section_counts, + }), + ..SolidHistoryLoft::default() + }) +} + +pub fn build_body( + sections: &[LoftSectionSelection], guides: &[Handle], path: Option, + available: &[(Handle, EntityType)], mode: ExtrudeMode, options: LoftOptions, +) -> Result { + cadkernel::acis::rebuild_loft_with_options(&record(sections, guides, path, available, mode, options)?) +} + +pub fn surface_entity(record: &SolidHistoryLoft) -> EntityType { + let settings = record.parameters.clone().unwrap_or_else(|| SolidHistoryLoftParameters { + normals: 0, ..Default::default() + }); + let mut surface = Surface::new(SurfaceKind::Lofted); + surface.surface_data = SurfaceData::Lofted { + loft_transform: record.base.transform, + cross_section_entities: record.cross_sections.clone(), guide_entities: record.guides.clone(), + path_entity: settings.path_entity, + plane_normal_lofting_type: settings.normals, + start_draft_angle: settings.start_draft_angle, end_draft_angle: settings.end_draft_angle, + start_draft_magnitude: settings.start_magnitude, end_draft_magnitude: settings.end_magnitude, + arc_length_parameterization: true, no_twist: true, align_direction: settings.align_direction, + simple_surfaces: true, closed_surfaces: settings.closed, solid: false, + ruled_surface: settings.normals == 0, virtual_guide: false, + cross_sections: Vec::new(), guide_curves: Vec::new(), path_curve: None, + }; + EntityType::Surface(surface) +} diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index 64ba59c73..a4666abb8 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -11,6 +11,7 @@ pub mod mesh_model; pub mod solid_model; pub mod solid_history; pub mod sweep_model; +pub mod loft_command_model; mod sweep_command_model; pub mod visual_style_model; pub mod object; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 177360a87..412ca4e8a 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,7 +1,7 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryNodeBase, SolidHistoryOperation, + SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; @@ -76,6 +76,15 @@ pub const PROP_AXIS_DIRECTION_X: &str = "solid_history_axis_direction_x"; pub const PROP_AXIS_DIRECTION_Y: &str = "solid_history_axis_direction_y"; pub const PROP_AXIS_DIRECTION_Z: &str = "solid_history_axis_direction_z"; pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; +pub const PROP_LOFT_TYPE: &str = "solid_history_loft_type"; +pub const PROP_LOFT_SECTION_COUNT: &str = "solid_history_loft_section_count"; +pub const PROP_LOFT_NORMALS: &str = "solid_history_loft_normals"; +pub const PROP_LOFT_START_DRAFT_ANGLE: &str = "solid_history_loft_start_draft_angle"; +pub const PROP_LOFT_END_DRAFT_ANGLE: &str = "solid_history_loft_end_draft_angle"; +pub const PROP_LOFT_START_MAGNITUDE: &str = "solid_history_loft_start_magnitude"; +pub const PROP_LOFT_END_MAGNITUDE: &str = "solid_history_loft_end_magnitude"; +pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed"; +pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -195,6 +204,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Torus(_) | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) + | SolidHistoryOperation::Loft(_) | SolidHistoryOperation::Extrusion(_) | SolidHistoryOperation::Revolve(_) ) @@ -519,6 +529,161 @@ fn sweep_properties( ] } +const LOFT_NORMAL_CHOICES: [&str; 7] = [ + "Ruled", + "Smooth", + "First normal", + "Last normal", + "Ends normal", + "All normal", + "Use draft angles", +]; + +fn loft_parameters(value: &SolidHistoryLoft) -> SolidHistoryLoftParameters { + value.parameters.clone().unwrap_or_else(|| SolidHistoryLoftParameters { + // Older history predates the options extension and rebuilds as ruled. + // Displaying or editing it must not silently change it to smooth. + normals: 0, + ..SolidHistoryLoftParameters::default() + }) +} + +pub fn loft_section_count(value: &SolidHistoryLoft) -> usize { + value.parameters.as_ref() + .filter(|parameters| !parameters.section_counts.is_empty()) + .map_or(value.cross_sections.len(), |parameters| parameters.section_counts.len()) +} + +pub fn loft_closed_editable(value: &SolidHistoryLoft) -> bool { + loft_section_count(value) >= 3 + && !value.cross_sections.iter().any(|section| matches!(section, EmbeddedEntity::Point(_))) +} + +fn loft_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryLoft, +) -> Vec { + let parameters = loft_parameters(value); + let kind = if parameters.path_entity.is_some() { + t!("Loft with path").into_owned() + } else if !value.guides.is_empty() { + t!("Loft with guides").into_owned() + } else { + t!("Loft with cross sections only").into_owned() + }; + let mut geometry = vec![ + Property { + label: t!("Solid type").into_owned(), + field: PROP_LOFT_TYPE, + value: PropValue::ReadOnly(kind), + }, + Property { + label: t!("Number of Cross sections").into_owned(), + field: PROP_LOFT_SECTION_COUNT, + value: PropValue::ReadOnly(loft_section_count(value).to_string()), + }, + Property { + label: t!("Surface Normals").into_owned(), + field: PROP_LOFT_NORMALS, + value: PropValue::Choice { + selected: LOFT_NORMAL_CHOICES.get(parameters.normals as usize) + .map_or_else(|| parameters.normals.to_string(), |name| (*name).to_string()), + options: LOFT_NORMAL_CHOICES.iter().map(|name| (*name).to_string()).collect(), + }, + }, + ]; + if parameters.normals == 6 { + geometry.extend([ + Property { + label: t!("Start draft angle").into_owned(), + field: PROP_LOFT_START_DRAFT_ANGLE, + value: PropValue::EditText(crate::entities::common::format_angle(parameters.start_draft_angle)), + }, + Property { + label: t!("End draft angle").into_owned(), + field: PROP_LOFT_END_DRAFT_ANGLE, + value: PropValue::EditText(crate::entities::common::format_angle(parameters.end_draft_angle)), + }, + Property { + label: t!("Start magnitude").into_owned(), + field: PROP_LOFT_START_MAGNITUDE, + value: PropValue::EditText(parameters.start_magnitude.to_string()), + }, + Property { + label: t!("End magnitude").into_owned(), + field: PROP_LOFT_END_MAGNITUDE, + value: PropValue::EditText(parameters.end_magnitude.to_string()), + }, + ]); + } + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let show_value = if show_history { "Yes" } else { "No" }.to_string(); + let closed_value = if parameters.closed { "Yes" } else { "No" }.to_string(); + let mut sections = vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: geometry, + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["Record".to_string(), "None".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: show_value, + options: vec!["Yes".to_string(), "No".to_string()], + } + } else { + PropValue::ReadOnly(show_value) + }, + }, + ], + }, + PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Closed").into_owned(), + field: PROP_LOFT_CLOSED, + value: if loft_closed_editable(value) { + PropValue::Choice { + selected: closed_value, + options: vec!["Yes".to_string(), "No".to_string()], + } + } else { + PropValue::ReadOnly(closed_value) + }, + }, + ], + }, + ]; + if parameters.closed { + sections[2].props.push(Property { + label: t!("Periodic").into_owned(), + field: PROP_LOFT_PERIODIC, + value: PropValue::Choice { + selected: if parameters.periodic { "Yes" } else { "No" }.to_string(), + options: vec!["Yes".to_string(), "No".to_string()], + }, + }); + } + sections +} + fn sweep_path_length(value: &SolidHistorySweep) -> Option { use acadrust::entities::EmbeddedEntity; use acadrust::EntityType; @@ -1209,6 +1374,7 @@ pub fn primitive_properties( SolidHistoryOperation::Pyramid(value) => pyramid_properties(document, handle, value), SolidHistoryOperation::Torus(value) => torus_properties(document, handle, value), SolidHistoryOperation::Sweep(value) => sweep_properties(document, handle, value), + SolidHistoryOperation::Loft(value) => loft_properties(document, handle, value), SolidHistoryOperation::Extrusion(value) => { extrusion_properties(document, handle, value) } @@ -1253,6 +1419,13 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_AXIS_DIRECTION_Y | PROP_AXIS_DIRECTION_Z | PROP_PYRAMID_TYPE + | PROP_LOFT_NORMALS + | PROP_LOFT_START_DRAFT_ANGLE + | PROP_LOFT_END_DRAFT_ANGLE + | PROP_LOFT_START_MAGNITUDE + | PROP_LOFT_END_MAGNITUDE + | PROP_LOFT_CLOSED + | PROP_LOFT_PERIODIC ) } @@ -1260,8 +1433,13 @@ pub fn is_history_choice(field: &str) -> bool { matches!(field, PROP_HISTORY | PROP_SHOW_HISTORY) } +pub fn is_loft_geometry_choice(field: &str) -> bool { + matches!(field, PROP_LOFT_NORMALS | PROP_LOFT_CLOSED | PROP_LOFT_PERIODIC) +} + pub fn is_specialized_property(field: &str) -> bool { - matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH) + matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH + | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT) || is_primitive_property(field) || is_history_choice(field) } @@ -1688,6 +1866,85 @@ fn apply_sweep_geometry_property( } } +fn apply_loft_geometry_property( + value: &mut SolidHistoryLoft, + field: &str, + text: &str, +) -> Option { + let current = loft_parameters(value); + let mut parameters = current.clone(); + match field { + PROP_LOFT_NORMALS => { + let Some(index) = LOFT_NORMAL_CHOICES.iter() + .position(|name| name.eq_ignore_ascii_case(text.trim())) + else { + return Some(false); + }; + parameters.normals = index as i32; + } + PROP_LOFT_START_DRAFT_ANGLE | PROP_LOFT_END_DRAFT_ANGLE => { + if parameters.normals != 6 { + return Some(false); + } + let Some(angle) = crate::entities::common::parse_angle(text) + .filter(|angle| angle.is_finite() && (0.0..=std::f64::consts::PI).contains(angle)) + else { + return Some(false); + }; + if field == PROP_LOFT_START_DRAFT_ANGLE { + parameters.start_draft_angle = angle; + } else { + parameters.end_draft_angle = angle; + } + } + PROP_LOFT_START_MAGNITUDE | PROP_LOFT_END_MAGNITUDE => { + if parameters.normals != 6 { + return Some(false); + } + let Some(magnitude) = text.trim().replace(',', ".").parse::().ok() + .filter(|magnitude| magnitude.is_finite() && *magnitude >= 0.0) + else { + return Some(false); + }; + if field == PROP_LOFT_START_MAGNITUDE { + parameters.start_magnitude = magnitude; + } else { + parameters.end_magnitude = magnitude; + } + } + PROP_LOFT_CLOSED => { + if !loft_closed_editable(value) { + return Some(false); + } + parameters.closed = if text.trim().eq_ignore_ascii_case("Yes") { + true + } else if text.trim().eq_ignore_ascii_case("No") { + false + } else { + return Some(false); + }; + } + PROP_LOFT_PERIODIC => { + if !parameters.closed { + return Some(false); + } + parameters.periodic = if text.trim().eq_ignore_ascii_case("Yes") { + true + } else if text.trim().eq_ignore_ascii_case("No") { + false + } else { + return Some(false); + }; + } + _ => return None, + } + if parameters == current { + return Some(false); + } + value.parameters = Some(parameters); + Some(true) +} + fn apply_torus_position_property( value: &mut SolidHistoryTorus, field: &str, @@ -1881,6 +2138,11 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Loft(loft_value) = operation { + if let Some(applied) = apply_loft_geometry_property(loft_value, field, value) { + return applied; + } + } if let SolidHistoryOperation::Extrusion(extrusion_value) = operation { if let Some(applied) = apply_extrusion_geometry_property(extrusion_value, field, value) @@ -2188,6 +2450,51 @@ fn embedded_entity(value: &EmbeddedEntity) -> Option { }) } +/// Visible LOFT construction curves, owned by the parent display entity. +/// Copies are transformed to WCS and never inserted into the document. +pub fn loft_visible_history_entities( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Vec { + let Some((_, object_show_history, show_history_mode)) = history_flags(document, handle) else { + return Vec::new(); + }; + if !displayed_history_state(object_show_history, show_history_mode).0 { + return Vec::new(); + } + let Some(SolidHistoryOperation::Loft(value)) = document.solid_history_operation(handle) else { + return Vec::new(); + }; + let Some(parent) = document.get_entity(handle) else { + return Vec::new(); + }; + let Some(transform) = matrix(value.base.transform) else { + return Vec::new(); + }; + let columns = transform.to_cols_array_2d(); + let affine = acadrust::types::Transform::from_matrix( + acadrust::types::Matrix4 { + m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])), + }, + ); + value.cross_sections.iter() + .chain(&value.guides) + .chain(value.parameters.as_ref().and_then(|parameters| parameters.path_entity.as_ref())) + .filter_map(|source| { + let mut entity = embedded_entity(source)?; + entity.apply_transform(&affine); + // Construction sources belong to the visible parent's layer and + // selection identity, not a deleted source or hidden source layer. + let common = entity.common_mut(); + common.handle = handle; + common.owner_handle = parent.common().owner_handle; + common.layer = parent.common().layer.clone(); + common.invisible = parent.common().invisible; + Some(entity) + }) + .collect() +} + fn into_embedded_entity(value: EntityType) -> Option { Some(match value { EntityType::Point(value) => EmbeddedEntity::Point(value), diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 6c43923a9..93a17416b 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -841,13 +841,29 @@ impl Scene { handle: Handle, operation: acadrust::objects::SolidHistoryOperation, ) -> bool { - self.solid_history_preview_wires.remove(&handle); let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; + let loft_surface_data = match (&operation, self.document.get_entity(handle)) { + (acadrust::objects::SolidHistoryOperation::Loft(record), Some(EntityType::Surface(_))) => { + let EntityType::Surface(surface) = crate::scene::model::loft_command_model::surface_entity(record) + else { return false; }; + Some(surface.surface_data) + } + (_, Some(EntityType::Solid3D(_))) => None, + _ => return false, + }; + let Some(display) = self.prepare_solid_model_display(handle, &body) else { + return false; + }; + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + && !display.0.complete + { + return false; + } self.record_solid_history_before(handle); if self .document @@ -856,12 +872,16 @@ impl Scene { { return false; } - let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { - return false; - }; - entity.set_sat_document(&document); - self.sync_solid_reference_point(handle); - self.register_solid_model(handle, body); + match self.document.get_entity_mut(handle) { + Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), + Some(EntityType::Surface(entity)) => { + entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + if let Some(data) = loft_surface_data { entity.surface_data = data; } + } + _ => return false, + } + self.register_prepared_solid_model(handle, body, display); + self.solid_history_preview_wires.remove(&handle); true } @@ -875,13 +895,28 @@ impl Scene { let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; - let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { + let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - entity.set_sat_document(&document); - self.sync_solid_reference_point(handle); + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + && !display.0.complete + { + return false; + } + match self.document.get_entity_mut(handle) { + Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), + Some(EntityType::Surface(entity)) => { + entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + if let acadrust::objects::SolidHistoryOperation::Loft(record) = &operation { + if let EntityType::Surface(updated) = crate::scene::model::loft_command_model::surface_entity(record) { + entity.surface_data = updated.surface_data; + } + } + } + _ => return false, + } + self.register_prepared_solid_model(handle, body, display); self.solid_history_preview_wires.remove(&handle); - self.register_solid_model(handle, body); true } @@ -900,9 +935,9 @@ impl Scene { { return false; } - let Some(EntityType::Solid3D(_)) = self.document.get_entity(handle) else { + if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_) | EntityType::Surface(_))) { return false; - }; + } self.solid_history_preview_wires.insert( handle, crate::scene::model::solid_model::grip_preview_wires(&body, handle), @@ -945,12 +980,16 @@ impl Scene { value: &str, ) -> bool { self.record_solid_history_before(handle); - crate::scene::model::solid_history::apply_history_choice( + let applied = crate::scene::model::solid_history::apply_history_choice( &mut self.document, handle, field, value, - ) + ); + if applied { + self.bump_entities(&[(handle, ChangeKind::Modified)]); + } + applied } fn apply_solid_history_grip( From abb8c91ca78166116ebd2c744f5e252338f29444 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:10:48 +0300 Subject: [PATCH 05/16] Use complete loft curve and orientation handling --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a525ee1c..ee31fc990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16#5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=36a2c56415f19c9e1be15d56592208e77bd73d0e#36a2c56415f19c9e1be15d56592208e77bd73d0e" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 2bf1ad6a8..7e965225f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5dbd3dfb9a29af3112c082a8de2d0587ec1b7b16", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "36a2c56415f19c9e1be15d56592208e77bd73d0e", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From c995995db5f891b1834e0b9456471e88b458f906 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:18:51 +0300 Subject: [PATCH 06/16] Use orientation-safe loft guide curves --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee31fc990..7c766a3fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=36a2c56415f19c9e1be15d56592208e77bd73d0e#36a2c56415f19c9e1be15d56592208e77bd73d0e" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3899c46e22e92d2e888f43405a36031365d6b1d5#3899c46e22e92d2e888f43405a36031365d6b1d5" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 7e965225f..3922dfc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "36a2c56415f19c9e1be15d56592208e77bd73d0e", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3899c46e22e92d2e888f43405a36031365d6b1d5", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From 8bd8ff3e3344d834ad388b70a24cebe1948be438 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:31:11 +0300 Subject: [PATCH 07/16] Fix LOFT scripted picks and binary history persistence --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- src/modules/insert/solid3d_cmds.rs | 10 +++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c766a3fb..d3793ddd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=da65c079396eb6e48d0f5d411eb0f87477479ee2#da65c079396eb6e48d0f5d411eb0f87477479ee2" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=0edbe8a945fa1de434d6ee88b2836dcaad459cbc#0edbe8a945fa1de434d6ee88b2836dcaad459cbc" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3899c46e22e92d2e888f43405a36031365d6b1d5#3899c46e22e92d2e888f43405a36031365d6b1d5" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052#0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3922dfc21..b5e08fe6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3899c46e22e92d2e888f43405a36031365d6b1d5", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0edbe8a945fa1de434d6ee88b2836dcaad459cbc", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "da65c079396eb6e48d0f5d411eb0f87477479ee2" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "0edbe8a945fa1de434d6ee88b2836dcaad459cbc" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 6b31dc3e7..1d460c714 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2138,6 +2138,14 @@ impl CadCommand for LoftCommand { if Self::keyword(text, &["UNDO"]).is_some() { return Some(self.undo_selection()); } + // Leave hexadecimal object identifiers to the shared pick-input router. + // Treating them as unknown options would consume scripted section, + // guide and path selections before on_entity_pick can receive them. + if self.needs_entity_pick() + && u64::from_str_radix(text.trim().trim_start_matches("0x"), 16).is_ok() + { + return None; + } match self.state.step { LoftStep::Sections => match Self::keyword(text, &["POINT", "JOIN", "MODE"]) { Some(0) => { @@ -2239,7 +2247,7 @@ impl CadCommand for LoftCommand { } LoftStep::StartBulge | LoftStep::EndBulge => { if !self.set_numeric_setting(text) { - return Some(self.invalid(t!("Enter a finite bulge magnitude greater than zero.").into_owned())); + return Some(self.invalid(t!("Enter a finite, non-negative bulge magnitude.").into_owned())); } } LoftStep::Closed | LoftStep::AlignDirection => { From 6e3c625e340a561be9a9cd7151dc67f9354c626a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:42:09 +0300 Subject: [PATCH 08/16] Use persistent LOFT surface history --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3793ddd0..5f5f37ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=0edbe8a945fa1de434d6ee88b2836dcaad459cbc#0edbe8a945fa1de434d6ee88b2836dcaad459cbc" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=41efc90be0dbf165a1a46213d0567b46664a22c3#41efc90be0dbf165a1a46213d0567b46664a22c3" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052#0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3f3d715f3082852d0c76b9123f79283deb65d267#3f3d715f3082852d0c76b9123f79283deb65d267" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index b5e08fe6c..93c47f258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0edbe8a945fa1de434d6ee88b2836dcaad459cbc", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0e7b97e4e6a22e4c46fb8b72e3bdd1dce3262052", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "41efc90be0dbf165a1a46213d0567b46664a22c3", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3f3d715f3082852d0c76b9123f79283deb65d267", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "0edbe8a945fa1de434d6ee88b2836dcaad459cbc" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "41efc90be0dbf165a1a46213d0567b46664a22c3" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 509e4b7e7fb76e7ca2cb9b8860d32e71860599c4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:52:18 +0300 Subject: [PATCH 09/16] Use stable repeated binary CAD serialization --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f5f37ebb..bdec330b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=41efc90be0dbf165a1a46213d0567b46664a22c3#41efc90be0dbf165a1a46213d0567b46664a22c3" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=580678d28f11ccbefbce3f9d3390d28ef4bbde80#580678d28f11ccbefbce3f9d3390d28ef4bbde80" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3f3d715f3082852d0c76b9123f79283deb65d267#3f3d715f3082852d0c76b9123f79283deb65d267" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5565ec3f23f6629974d2a5fed5215d2b92738a58#5565ec3f23f6629974d2a5fed5215d2b92738a58" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 93c47f258..60315f9b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "41efc90be0dbf165a1a46213d0567b46664a22c3", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3f3d715f3082852d0c76b9123f79283deb65d267", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5565ec3f23f6629974d2a5fed5215d2b92738a58", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "41efc90be0dbf165a1a46213d0567b46664a22c3" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 9279384e9a76d00b5d7403f4fcc8f16537b0444d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:31:30 +0300 Subject: [PATCH 10/16] Complete PRESSPULL selection, face editing and extrusion history --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 60 +---- src/app/commands/display.rs | 16 +- src/app/mod.rs | 1 + src/app/model_ops.rs | 61 +---- src/app/presspull_ops.rs | 244 +++++++++++++++++++ src/app/properties.rs | 7 +- src/app/update/viewport.rs | 148 +++++++++++- src/command.rs | 39 ++- src/modules/insert/solid3d_cmds.rs | 344 ++++++++++++++++++++++---- src/scene/mod.rs | 19 +- src/scene/model/mod.rs | 1 + src/scene/model/presspull_model.rs | 376 +++++++++++++++++++++++++++++ src/scene/model/solid_history.rs | 97 +++++--- src/scene/modify.rs | 114 +++++++-- 16 files changed, 1284 insertions(+), 247 deletions(-) create mode 100644 src/app/presspull_ops.rs create mode 100644 src/scene/model/presspull_model.rs diff --git a/Cargo.lock b/Cargo.lock index bdec330b9..ea8fe4f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5565ec3f23f6629974d2a5fed5215d2b92738a58#5565ec3f23f6629974d2a5fed5215d2b92738a58" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=37a33babe7d64e05d9d4144981eeb9bc8ebf390e#37a33babe7d64e05d9d4144981eeb9bc8ebf390e" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 60315f9b5..b77d57a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5565ec3f23f6629974d2a5fed5215d2b92738a58", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "37a33babe7d64e05d9d4144981eeb9bc8ebf390e", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 5327b777a..992158d88 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3792,61 +3792,11 @@ impl OpenCADStudio { self.refresh_properties(); } - CmdResult::PresspullEntity { - handle, - pick, - distance, - drag, - color, - } => { - if matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)) - ) { - let task = self.solid_face_presspull(handle, pick, distance, drag); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - if self.reject_locked_edit(i, handle) { - self.tabs[i].active_cmd = None; - return Task::none(); - } - use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_history, sweep_model}; - - let result = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { - let distance = match drag { - Some(point) => sweep_model::projected_drag(entity, pick, point)?, - None => distance, - }; - sweep_model::extruded(entity, distance) - }); - if let Some(solid) = result { - let pending = self.begin_undo(i, "PRESSPULL", 1, true); - let history = solid_history::brep_op(&solid); - let created = self.add_solid_model(empty_solid3d(), solid, history); - let _ = color; - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("PRESSPULL: solid created.").as_ref()); - if let Some(delta) = pending { - self.commit_undo_delta(i, delta); - } - } - } else { - self.command_line.push_error( - crate::t!("PRESSPULL: select a closed profile or planar solid face.") - .as_ref(), - ); - } - 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::PresspullPick { handle, point, offset, multiple: _ } => { + self.presspull_pick(handle, point, offset); + } + CmdResult::PresspullApply { targets, distance, color: _ } => { + self.presspull_apply(targets, distance); } // ── REVOLVE ──────────────────────────────────────────────────── diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index cc32048c2..0b5ec6aa7 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -771,22 +771,10 @@ impl OpenCADStudio { "PRESSPULL" => { use crate::modules::insert::solid3d_cmds::PresspullCommand; - let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let mut command = PresspullCommand::new(color); - if selected.len() == 1 - && !matches!(selected[0].1, acadrust::EntityType::Solid3D(_)) - { - if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { - command.set_entity_pick_direction( - curve.plane.normal().map(glam::DVec3::from_array), - ); - command.on_entity_pick( - selected[0].0, - glam::DVec3::from_array(curve.plane.origin), - ); - } - } + command.set_isolines(self.tabs[i].scene.document.header.isolines.max(0) as usize); + command.set_preselection(self.presspull_preselection()); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/mod.rs b/src/app/mod.rs index a8c407776..3053b067f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; mod properties; +mod presspull_ops; mod recent; pub(crate) mod settings; mod shortcuts; diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index b1ea73dc7..38c287d23 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -119,6 +119,12 @@ impl super::OpenCADStudio { entity.history_handle = None; entity.set_sat_document(&document); + let Some(display) = self.tabs[i].scene.prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + return false; + }; self.push_undo_snapshot(i, label); self.tabs[i].scene.delete_solid_history(handle); if !self.tabs[i] @@ -131,7 +137,7 @@ impl super::OpenCADStudio { } let history = solid_history::brep_op(&result); self.tabs[i].scene.create_solid_history(handle, history); - self.tabs[i].scene.register_solid_model(handle, result); + self.tabs[i].scene.register_prepared_solid_model(handle, result, display); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(handle, false); self.tabs[i].dirty = true; @@ -189,59 +195,6 @@ impl super::OpenCADStudio { Task::none() } - pub(super) fn solid_face_presspull( - &mut self, - handle: Handle, - pick: glam::DVec3, - distance: f64, - drag: Option, - ) -> Task { - let i = self.active_tab; - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - self.tabs[i].scene.restore_solid_models(&[handle]); - let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { - self.command_line - .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); - return Task::none(); - }; - let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else { - self.command_line - .push_error(crate::t!("Select a planar solid face.").as_ref()); - return Task::none(); - }; - let distance = match drag { - Some(point) => { - let Some(normal) = solid_model::planar_face_normal(&body, face) else { - self.command_line - .push_error(crate::t!("Select a planar solid face.").as_ref()); - return Task::none(); - }; - (point - pick).dot(glam::DVec3::from_array(normal)) - } - None => distance, - }; - if !distance.is_finite() || distance.abs() <= 1e-6 { - self.command_line.push_error( - crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(), - ); - return Task::none(); - } - let Some(result) = cadkernel::brep::presspull(&body, face, distance) else { - self.command_line.push_error( - crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.") - .as_ref(), - ); - return Task::none(); - }; - if self.replace_solid_body(handle, result, "PRESSPULL") { - self.command_line - .push_output(crate::t!("PRESSPULL: solid updated.").as_ref()); - } - Task::none() - } - /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { let i = self.active_tab; diff --git a/src/app/presspull_ops.rs b/src/app/presspull_ops.rs new file mode 100644 index 000000000..e9e729946 --- /dev/null +++ b/src/app/presspull_ops.rs @@ -0,0 +1,244 @@ +//! PRESSPULL scene integration. Resolve first; build and validate the complete +//! batch before recording undo or changing any persistent entity. + +use acadrust::entities::{AcisData, EmbeddedEntity, Solid3D, Surface, SurfaceKind, Wire}; +use acadrust::objects::{DynamicBlockData, ObjectType, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use cadkernel::brep::{self, Body, PlanarFaceProfile, PresspullMode}; +use glam::DVec3; +use crate::scene::model::{solid_history, sweep_model}; +use crate::scene::model::mesh_model::MeshLodSet; +use crate::scene::model::presspull_model::{self, PresspullTarget, PresspullTargetKind}; + +struct PreparedPresspull { + owner: Option, + entity: EntityType, + body: Body, + history: SolidHistoryOperation, + display: (MeshLodSet, Vec, [f64; 3]), +} + +fn extrusion_history(entity: &EntityType, direction: DVec3, anchor: DVec3) -> Option { + if let EntityType::Region(region) = entity { + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + return Some(SolidHistoryOperation::Extrusion(SolidHistorySweep { + base, + operation_major: 1, + sweep_entity: Some(EmbeddedEntity::Region(region.clone())), + direction: Vector3::new(direction.x, direction.y, direction.z), + end_draft_distance: direction.length(), + scale_factor: 1.0, + sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new(anchor.x, anchor.y, anchor.z), + ..SolidHistorySweep::default() + })); + } + sweep_model::extrusion_history(entity, None, direction.to_array(), 0.0, anchor.to_array()) +} + +impl super::OpenCADStudio { + fn restore_presspull_bodies(&mut self) { + let scene = &mut self.tabs[self.active_tab].scene; + let handles = scene.document.entities().filter_map(|entity| { + matches!(entity, EntityType::Solid3D(_)).then_some(entity.common().handle) + }).collect::>(); + scene.restore_solid_models(&handles); + } + + pub(super) fn presspull_preselection(&mut self) -> Vec { + self.restore_presspull_bodies(); + let tab = &self.tabs[self.active_tab]; + let plane = tab.ucs_xform().working_plane(); + tab.scene.selected_handles_in_order().into_iter().filter_map(|handle| { + let entity = tab.scene.document.get_entity(handle)?; + let (profile_plane, loops, _) = presspull_model::profile_geometry(entity)?; + let first = loops.first()?.first()?.point_at(0.0); + let anchor = DVec3::from_array(profile_plane.point_at(first)); + presspull_model::resolve_target(&tab.scene, Some(handle), anchor, plane, false).ok() + }).collect() + } + + pub(super) fn presspull_pick(&mut self, handle: Option, point: DVec3, offset: bool) { + self.restore_presspull_bodies(); + let i = self.active_tab; + let target = presspull_model::resolve_target( + &self.tabs[i].scene, handle, point, self.tabs[i].ucs_xform().working_plane(), offset, + ); + match target { + Ok(target) => { + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_target(target); + } + self.tabs[i].scene.set_hover_highlight(None); + } + Err(error) => self.command_line.push_error(&error), + } + self.refresh_presspull_prompt(); + } + + fn refresh_presspull_prompt(&mut self) { + if let Some(command) = self.tabs[self.active_tab].active_cmd.as_ref() { + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + } + self.sync_dyn_fields(); + } + + fn prepare_presspull(&self, targets: &[PresspullTarget], distance: f64) -> Result, String> { + if targets.is_empty() || !distance.is_finite() || distance.abs() <= 1e-9 { + return Err("PRESSPULL: enter a finite non-zero height.".into()); + } + let scene = &self.tabs[self.active_tab].scene; + let mut results: Vec<(Option, EntityType, Body, SolidHistoryOperation)> = Vec::new(); + for target in targets { + let owner = match &target.kind { + PresspullTargetKind::Profile { source, entity, owner } => { + if let Some(source) = source { + if scene.is_layer_locked(*source) || scene.document.get_entity(*source) != Some(entity) { + return Err("PRESSPULL: a selected source changed. Select it again.".into()); + } + } + *owner + } + PresspullTargetKind::Face { handle, .. } => Some(*handle), + }; + if owner.is_some_and(|handle| scene.is_layer_locked(handle)) { + return Err("PRESSPULL: the solid is on a locked layer.".into()); + } + let previous = owner.and_then(|handle| results.iter().position(|result| result.0 == Some(handle))); + let current = previous.map(|index| &results[index].2) + .or_else(|| owner.and_then(|handle| scene.solid_models.get(&handle))); + let (body, history, mut entity) = match &target.kind { + PresspullTargetKind::Profile { entity, .. } => { + let (plane, loops, closed) = presspull_model::profile_geometry(entity) + .ok_or("PRESSPULL: the profile could not be recovered exactly.")?; + if owner.is_some() { + let current = current.ok_or("PRESSPULL: the original solid could not be restored.")?; + if !closed { + return Err("PRESSPULL: an attached region must be closed.".into()); + } + let profile = PlanarFaceProfile { plane, loops, outward: target.direction.to_array() }; + let body = brep::presspull_region(current, &profile, distance) + .ok_or("PRESSPULL: the region could not be added to or cut from the solid.")?; + let history = solid_history::brep_op(&body); + (body, history, scene.document.get_entity(owner.unwrap()).cloned() + .ok_or("PRESSPULL: the original solid no longer exists.")?) + } else { + let direction = target.direction * distance; + let body = presspull_model::extrusion_body(entity, direction.to_array()) + .ok_or("PRESSPULL: the profile could not be extruded with this height.")?; + let history = extrusion_history(entity, direction, target.anchor) + .ok_or("PRESSPULL: the editable extrusion profile could not be retained.")?; + let entity = if closed { + EntityType::Solid3D(Solid3D::new()) + } else { + let mut surface = Surface::new(SurfaceKind::Extruded); + if let SolidHistoryOperation::Extrusion(record) = &history { + surface.surface_data = solid_history::extrusion_surface_data(record) + .ok_or("PRESSPULL: the surface parameters could not be retained.")?; + } + EntityType::Surface(surface) + }; + (body, history, entity) + } + } + PresspullTargetKind::Face { face, offset, .. } => { + let current = current.ok_or("PRESSPULL: the original solid could not be restored.")?; + // Earlier targets in the same batch may have rebuilt face keys. + let face = if previous.is_some() { + brep::planar_face_at_point(current, target.anchor.to_array(), 1e-6) + .ok_or("PRESSPULL: an earlier selected region changed this face.")? + } else { *face }; + let body = brep::presspull_face(current, face, distance, + if *offset { PresspullMode::Offset } else { PresspullMode::Extrude }) + .ok_or("PRESSPULL: the requested face operation would invalidate the solid.")?; + let history = solid_history::brep_op(&body); + (body, history, scene.document.get_entity(owner.unwrap()).cloned() + .ok_or("PRESSPULL: the original solid no longer exists.")?) + } + }; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("PRESSPULL: the result could not be encoded. The original objects were retained.")?; + if owner.is_none() { entity.common_mut().plotstyle_flags = 2; } + match &mut entity { + EntityType::Solid3D(solid) => { + solid.history_handle = None; + solid.silhouettes.clear(); + solid.set_sat_document(&sat); + } + EntityType::Surface(surface) => { + surface.history_handle = None; + surface.silhouettes.clear(); + surface.acis_data = AcisData::from_sat(&sat.to_sat_string()); + } + _ => return Err("PRESSPULL: unsupported result object.".into()), + } + let result = (owner, entity, body, history); + if let Some(index) = previous { results[index] = result; } else { results.push(result); } + } + results.into_iter().map(|(owner, entity, body, history)| { + let display = scene.prepare_solid_model_display(owner.unwrap_or(Handle::NULL), &body) + .filter(|display| display.0.complete && display.0.lods.iter().any(|mesh| !mesh.indices.is_empty())) + .ok_or("PRESSPULL: the complete result could not be displayed. The original objects were retained.")?; + Ok(PreparedPresspull { owner, entity, body, history, display }) + }).collect() + } + + pub(super) fn presspull_apply(&mut self, targets: Vec, distance: f64) { + let i = self.active_tab; + let prepared = match self.prepare_presspull(&targets, distance) { + Ok(prepared) => prepared, + Err(error) => { + self.command_line.push_error(&error); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_applied(false); + } + self.refresh_presspull_prompt(); + return; + } + }; + let pending = self.begin_undo(i, "PRESSPULL", prepared.len(), true); + let mut handles = Vec::new(); + // All fallible geometry, persistence and display work finished above. + // No source curve is deleted by PRESSPULL. + for result in prepared { + let handle = if let Some(handle) = result.owner { + self.tabs[i].scene.delete_solid_history(handle); + self.tabs[i].scene.update_entity(result.entity); + handle + } else { + self.commit_entity_handle(result.entity).expect("validated solid/surface insertion") + }; + self.tabs[i].scene.create_solid_history(handle, result.history); + // PRESSPULL keeps editable construction parameters without enabling + // recording of subsequent solid operations by default. + if result.owner.is_none() { + let document = &mut self.tabs[i].scene.document; + if let Some(graph) = document.solid_history_graph(handle) { + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&graph.root) { + if let DynamicBlockData::SolidHistory(history) = &mut object.data { + history.record_history = false; + } + } + } + } + self.tabs[i].scene.register_prepared_solid_model(handle, result.body, result.display); + handles.push(handle); + } + self.tabs[i].scene.deselect_all(); + for handle in &handles { self.tabs[i].scene.select_entity(*handle, false); } + self.tabs[i].dirty = true; + if let Some(pending) = pending { self.commit_undo_delta(i, pending); } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_applied(true); + } + self.command_line.push_output(crate::tf!("PRESSPULL: %{count} object(s) updated.", count = handles.len()).as_ref()); + self.refresh_properties(); + self.refresh_presspull_prompt(); + } +} diff --git a/src/app/properties.rs b/src/app/properties.rs index a08166252..8a02a4fff 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -448,12 +448,7 @@ impl OpenCADStudio { let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); if specialized_primitive { - sections.retain(|section| { - !section.props.iter().any(|property| { - property.field.starts_with("acis_") - || property.field.starts_with("s3d_") - }) - }); + retain_specialized_primitive_sections(&mut sections); } sections.extend( crate::scene::model::solid_history::primitive_properties( diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 27126d77c..3474330de 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -494,6 +494,25 @@ impl OpenCADStudio { } } + /// An acquired planar profile can lie away from the current UCS plane. + /// Reproject the cursor onto that profile before using it as a drag anchor + /// or looking for its supporting solid face. + fn profile_pick_point( + &self, i: usize, handle: Handle, + edit_cam: &Option, + cursor: iced::Point, bounds: iced::Rectangle, + ) -> Option { + let entity = self.tabs[i].scene.document.get_entity(handle)?; + let (plane, _, _) = crate::scene::model::presspull_model::profile_geometry(entity)?; + let normal = glam::DVec3::from_array(plane.normal()?).as_vec3(); + let origin = glam::DVec3::from_array(plane.origin); + let point = match edit_cam { + Some(camera) => camera.pick_on_plane(cursor, bounds, normal, origin), + None => self.tabs[i].scene.camera.borrow().pick_on_plane(cursor, bounds, normal, origin), + }; + point.is_finite().then_some(point) + } + /// Projection + hit-test wires for the active pane. Inside a floating /// viewport (`edit_cam` Some) it returns the viewport camera and the live /// **model** wires, so wire / hatch picking lands on the entity under the @@ -922,6 +941,12 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + // Modifier-driven selection must be known before cursor_plane/axis and + // drafting constraints are read, not merely before the final callback. + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } let perf_move = crate::perf::enabled(); let move_started = Instant::now(); @@ -1765,7 +1790,9 @@ impl OpenCADStudio { // O(N) per frame and stalls the cursor on large drawings, // so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only // once the cursor has been still for `HOVER_DWELL_MS`. - if !dragging && self.tabs[i].active_cmd.is_none() { + let deferred_command_hover = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + if !dragging && (self.tabs[i].active_cmd.is_none() || deferred_command_hover) { // On dense drawings, clearing a rollover immediately schedules a // second full scene frame just as motion resumes. Keep the previous // highlight until the next settled pick replaces it. @@ -2090,21 +2117,25 @@ impl OpenCADStudio { } pt }; - let effective = self.tabs[i] + let effective = if needs_entity && self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + cursor_world + } else { self.tabs[i] .active_cmd .as_ref() .and_then(|command| command.cursor_axis()) .and_then(|(origin, direction)| { cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) }) - .unwrap_or(effective); + .unwrap_or(effective) }; // Dynamic-input locked fields constrain the preview point // (#356): a typed angle pins the direction, a typed // distance pins the radius — the same resolution the Enter // commit uses, so preview and commit agree. The lock wins // over osnap/ortho/polar. let effective = { - let locked = self.tabs[i].active_cmd.is_some() + let locked = !needs_entity && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()); if locked { self.tabs[i].last_cursor_world = effective; @@ -2224,6 +2255,10 @@ impl OpenCADStudio { p.extend(cmd.on_preview_wires(effective)); } p + } else if needs_entity && deferred_command_hover { + // The dwell callback resolves and outlines bounded areas once. + // Moving the cursor clears that outline without rebuilding it. + Vec::new() } else if needs_entity { let include_fills = self.tabs[i] .active_cmd @@ -2459,6 +2494,12 @@ impl OpenCADStudio { pub(crate) fn on_viewport_exit(&mut self) -> Task { let i = self.active_tab; + self.hover_dwell = None; + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + { + self.tabs[i].scene.clear_preview_wire(); + } let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.left_down = false; sel.left_press_pos = None; @@ -2795,6 +2836,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } // A left-click during a command resets the right-click cycle, so // the next right-click acts as Enter again rather than opening // the context menu. @@ -3074,6 +3119,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_release(&mut self) -> Task { let i = self.active_tab; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } // Navigation mode: end this drag but keep the tool armed for the next // left drag (exit is Esc / another command). Mirror of the press. @@ -3493,7 +3542,7 @@ impl OpenCADStudio { // A click while dynamic-input fields hold typed values // commits the CONSTRAINED point — the same resolution // the preview shows and Enter would commit (#356). - if self.tabs[i].active_cmd.is_some() + if !needs_entity_click && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()) { self.tabs[i].last_cursor_world = pt; @@ -3501,7 +3550,13 @@ impl OpenCADStudio { pt = r; } } - pt + if needs_entity_click && self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + raw + } else { + pt + } }; // `world_pt` is in offset-relative (local) space, matching @@ -3520,6 +3575,10 @@ impl OpenCADStudio { world_pt + glam::DVec3::new(wo[0], wo[1], wo[2]) }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } let result = if self.tabs[i] .active_cmd .as_ref() @@ -3631,11 +3690,26 @@ impl OpenCADStudio { self.tabs[i] .scene .solid_click_point_for(p, view_rot2, eye2, bounds, handle) - .unwrap_or(pick_wcs) + .or_else(|| self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + .then(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)).flatten()) + .unwrap_or_else(|| { + let bounded_pick = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()); + if bounded_pick && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) { + // The aperture caught an edge outside its face. + // Do not reinterpret the working-plane point as + // a hit on another cap of the same body. + glam::DVec3::NAN + } else { pick_wcs } + }) } else { pick_wcs }; - let entity_pick_direction = if uses_surface_point { + let entity_pick_direction = if uses_surface_point && entity_pick_point.is_finite() { if matches!( self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)) @@ -3749,6 +3823,10 @@ impl OpenCADStudio { } } result + } else if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd .as_ref() @@ -4771,7 +4849,9 @@ impl OpenCADStudio { /// ends. Repeated wheel events replace this timestamp, so the pick runs only /// after the final zoom step settles. pub(in crate::app) fn arm_hover_after_navigation(&mut self, i: usize) { - if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() { + if i >= self.tabs.len() || self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| !command.entity_pick_deferred_hover()) + { return; } let (cursor, canvas_size) = { @@ -5062,12 +5142,19 @@ impl OpenCADStudio { let i = dwell.tab; // Re-check the gate — drag / command may have started // between the move that armed the dwell and this tick. - if i >= self.tabs.len() { + if i >= self.tabs.len() || i != self.active_tab { self.hover_dwell = None; return Task::none(); } + self.push_ucs_to_cmd(i); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } + let deferred_command = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); let navigating = self.tabs[i].scene.selection.borrow().middle_down; - if self.tabs[i].active_cmd.is_some() || navigating { + if (self.tabs[i].active_cmd.is_some() && !deferred_command) || navigating { self.clear_navigation_hover(i); return Task::none(); } @@ -5158,16 +5245,51 @@ impl OpenCADStudio { } if hovered.is_none() { let started = Instant::now(); - hovered = self.tabs[i].scene.solid_edge_hover_hit( + hovered = if deferred_command { self.tabs[i].scene.solid_click_hit( + p, view_rot, eye, bounds, candidate_handles.as_ref(), + ) } else { self.tabs[i].scene.solid_edge_hover_hit( p, view_rot, eye, bounds, candidate_handles.as_ref(), crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ); + ) }; solid_ms = started.elapsed().as_secs_f64() * 1000.0; } + if deferred_command { + let surface_point = hovered.and_then(|handle| { + self.tabs[i].scene.solid_click_point_for(p, view_rot, eye, bounds, handle) + .or_else(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)) + }); + let point = surface_point.unwrap_or(hover_world); + let is_solid = hovered.is_some_and(|handle| matches!( + self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)), + )); + // An aperture may catch an edge beside, rather than on, a face. + // Never send its XY-plane fallback as a 3D face pick. + if is_solid && surface_point.is_none() { + self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.clear_preview_wire(); + self.hover_dwell = None; + return Task::none(); + } + let handles = self.tabs[i].scene.document.entities().filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }).collect::>(); + self.tabs[i].scene.restore_solid_models(&handles); + let show = (self.model_space.selection_preview & 2) != 0; + let previews = if show { + let tab = &mut self.tabs[i]; + tab.active_cmd.as_mut().map(|command| { + command.on_deferred_entity_hover(&tab.scene, hovered, point) + }).unwrap_or_default() + } else { Vec::new() }; + self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_preview_wires(previews); + self.hover_dwell = None; + return Task::none(); + } let preview_idle = (self.model_space.selection_preview & 1) != 0; if preview_idle { self.tabs[i].scene.set_hover_highlight(hovered); diff --git a/src/command.rs b/src/command.rs index 4a7e983cc..12222ac40 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1507,12 +1507,17 @@ pub enum CmdResult { taper_angle: f64, color: [f32; 4], }, - /// Pull a closed profile or a planar solid face by a signed distance. - PresspullEntity { - handle: Handle, - pick: DVec3, + /// Resolve a profile, bounded area, or solid face for PRESSPULL. + PresspullPick { + handle: Option, + point: DVec3, + offset: bool, + multiple: bool, + }, + /// Apply a signed distance to the resolved PRESSPULL selection. + PresspullApply { + targets: Vec, distance: f64, - drag: Option, color: [f32; 4], }, /// Revolve the profile entities around the given axis. @@ -1937,6 +1942,19 @@ pub trait CadCommand: Send { false } + /// Expensive bounded-area acquisition runs once after cursor dwell, not + /// from every pointer event. The host supplies the same WCS surface pick + /// used for clicks, and clears the overlay when movement resumes. + fn entity_pick_deferred_hover(&self) -> bool { + false + } + + fn on_deferred_entity_hover( + &mut self, _scene: &Scene, _handle: Option, _point: DVec3, + ) -> Vec { + Vec::new() + } + /// Called when the text editor closes, either because the user committed or cancelled the edit. fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { CmdResult::Cancel @@ -1953,6 +1971,17 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Supply a resolved PRESSPULL target after an object or bounded-area pick. + fn on_presspull_target( + &mut self, + _target: crate::scene::model::presspull_model::PresspullTarget, + ) { + } + + /// Resume PRESSPULL after an atomic apply attempt. A failed operation keeps + /// the current targets available for another distance or selection undo. + fn on_presspull_applied(&mut self, _success: bool) {} + /// Host callback after `CmdResult::CommitLiveEntity`: records the handle the /// new live entity was assigned so later `UpdateLiveEntity` results can /// target it. diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 1d460c714..26e076108 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -10,6 +10,7 @@ use crate::command::{ LoftSectionSelection, SelectionEntity, SweepOptions, WorkingPlane, }; use crate::scene::WireModel; +use crate::scene::model::presspull_model::{PresspullTarget, PresspullTargetKind}; use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -515,19 +516,125 @@ impl CadCommand for ExtrudeCommand { // ── PRESSPULL command ───────────────────────────────────────────────────── pub struct PresspullCommand { - picked: Option<(acadrust::Handle, DVec3)>, - direction: Option, + step: PresspullStep, + targets: Vec, + ctrl: bool, + shift: bool, + isolines: usize, + target_generation: u64, + preview_key: Option<(u64, u64)>, + preview_cache: Vec, + working_plane: WorkingPlane, + hover_key: Option<(u64, Option, [u64; 3], bool)>, + hover_cache: Vec, color: [f32; 4], } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PresspullStep { + Pick, + Multiple, + Height, +} + impl PresspullCommand { pub fn new(color: [f32; 4]) -> Self { Self { - picked: None, - direction: None, + step: PresspullStep::Pick, + targets: Vec::new(), + ctrl: false, + shift: false, + isolines: 0, + target_generation: 0, + preview_key: None, + preview_cache: Vec::new(), + working_plane: WorkingPlane::default(), + hover_key: None, + hover_cache: Vec::new(), color, } } + + pub fn set_isolines(&mut self, isolines: usize) { + if self.isolines != isolines { + self.isolines = isolines; + self.invalidate_preview(); + } + } + + pub fn set_preselection(&mut self, targets: Vec) { + self.targets.clear(); + self.step = PresspullStep::Pick; + self.invalidate_preview(); + for target in targets { + self.on_presspull_target(target); + } + } + + fn invalidate_preview(&mut self) { + self.target_generation = self.target_generation.wrapping_add(1); + self.preview_key = None; + self.preview_cache.clear(); + self.hover_key = None; + self.hover_cache.clear(); + } + + fn has_target(&self, target: &PresspullTarget) -> bool { + self.targets.iter().any(|selected| match (&selected.kind, &target.kind) { + ( + PresspullTargetKind::Profile { source: Some(first), .. }, + PresspullTargetKind::Profile { source: Some(second), .. }, + ) => first == second, + ( + PresspullTargetKind::Face { handle: first, face: first_face, .. }, + PresspullTargetKind::Face { handle: second, face: second_face, .. }, + ) => first == second && first_face == second_face, + ( + PresspullTargetKind::Profile { source: None, entity: first, owner: first_owner }, + PresspullTargetKind::Profile { source: None, entity: second, owner: second_owner }, + ) => first_owner == second_owner && first == second, + _ => false, + }) + } + + fn pick(&self, handle: Option, point: DVec3) -> CmdResult { + if !point.is_finite() { + return CmdResult::NeedPoint; + } + CmdResult::PresspullPick { + handle: handle.filter(|handle| !handle.is_null()), + point, + offset: self.ctrl, + multiple: self.step == PresspullStep::Multiple || self.shift, + } + } + + fn height(&self, point: DVec3) -> Option { + let target = self.targets.last()?; + let distance = (point - target.anchor).dot(target.direction); + distance.is_finite().then_some(distance) + } + + fn finish(&self, distance: f64) -> CmdResult { + if self.targets.is_empty() || !distance.is_finite() || distance.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + CmdResult::PresspullApply { + targets: self.targets.clone(), + distance, + color: self.color, + } + } + + fn undo_selection(&mut self) -> CmdResult { + if self.targets.pop().is_some() { + self.invalidate_preview(); + } + if self.targets.is_empty() { + self.step = PresspullStep::Pick; + } + CmdResult::NeedPoint + } } impl CadCommand for PresspullCommand { @@ -536,15 +643,37 @@ impl CadCommand for PresspullCommand { } fn prompt(&self) -> String { - if self.picked.is_none() { - t!("PRESSPULL Select a closed profile or planar solid face:").into_owned() - } else { - t!("PRESSPULL Signed distance:").into_owned() + match self.step { + PresspullStep::Pick => { + t!("PRESSPULL Select object or bounded area (Enter to finish):").into_owned() + } + PresspullStep::Multiple => { + t!("PRESSPULL Select additional objects or bounded areas [Undo] (Enter for height):").into_owned() + } + PresspullStep::Height => { + t!("PRESSPULL Specify signed extrusion height or [Multiple/Undo]:").into_owned() + } + } + } + + fn options(&self) -> Vec { + match self.step { + PresspullStep::Pick => vec![CmdOption::enter("Done")], + PresspullStep::Multiple => { + vec![CmdOption::new("Undo", "UNDO"), CmdOption::enter("Height")] + } + PresspullStep::Height => { + vec![CmdOption::new("Multiple", "MULTIPLE"), CmdOption::new("Undo", "UNDO")] + } } } fn needs_entity_pick(&self) -> bool { - self.picked.is_none() + self.step != PresspullStep::Height || self.shift + } + + fn entity_pick_accepts_points(&self) -> bool { + true } fn entity_pick_includes_fills(&self) -> bool { @@ -555,69 +684,166 @@ impl CadCommand for PresspullCommand { true } - fn set_entity_pick_direction(&mut self, direction: Option) { - self.direction = direction.and_then(|value| value.try_normalize()); + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + if self.working_plane.origin != plane.origin || self.working_plane.x != plane.x + || self.working_plane.y != plane.y + { + self.hover_key = None; + self.hover_cache.clear(); + } + self.working_plane = plane; + } + + fn set_shift(&mut self, shift: bool) { + self.shift = shift; } fn entity_pick_highlights_hover(&self) -> bool { true } + fn entity_pick_deferred_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn on_deferred_entity_hover( + &mut self, scene: &crate::scene::Scene, handle: Option, point: DVec3, + ) -> Vec { + if !self.needs_entity_pick() { + return Vec::new(); + } + let key = (scene.geometry_epoch, handle, point.to_array().map(f64::to_bits), self.ctrl); + if self.hover_key == Some(key) { + return self.hover_cache.clone(); + } + self.hover_key = Some(key); + self.hover_cache.clear(); + let Ok(target) = crate::scene::model::presspull_model::resolve_target( + scene, handle, point, self.working_plane, self.ctrl, + ) else { return Vec::new(); }; + let geometry = match target.kind { + PresspullTargetKind::Profile { entity, .. } => + crate::scene::model::presspull_model::profile_geometry(&entity) + .map(|(plane, loops, _)| (plane, loops)), + PresspullTargetKind::Face { body, face, .. } => + cadkernel::brep::planar_face_profile(&body, face) + .map(|profile| (profile.plane, profile.loops)), + }; + if let Some((plane, loops)) = geometry { + self.hover_cache = loops.into_iter().flatten().filter_map(|curve| { + let points = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .into_iter().map(|point| plane.point_at(point)).collect::>(); + (points.len() >= 2).then(|| { + let mut wire = WireModel::solid_f64( + "PRESSPULL-BOUNDARY-HOVER".to_owned(), points, [1.0, 0.65, 0.1, 1.0], false, + ); + wire.line_weight_px = 2.0; + wire + }) + }).collect(); + } + self.hover_cache.clone() + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { - if handle.is_null() { + if !self.needs_entity_pick() { return CmdResult::NeedPoint; } - self.picked = Some((handle, point)); - CmdResult::NeedPoint + self.pick(Some(handle), point) } fn on_point(&mut self, point: DVec3) -> CmdResult { - let Some((handle, pick)) = self.picked else { - return CmdResult::NeedPoint; - }; - let distance = self - .direction - .map(|direction| (point - pick).dot(direction)) - .unwrap_or_else(|| point.distance(pick)); - if !distance.is_finite() || distance.abs() <= 1e-6 { - return CmdResult::NeedPoint; + if self.needs_entity_pick() { + return self.pick(None, point); } - CmdResult::PresspullEntity { - handle, - pick, - distance, - drag: Some(point), - color: self.color, + self.height(point).map_or(CmdResult::NeedPoint, |height| self.finish(height)) + } + + fn on_presspull_target(&mut self, mut target: PresspullTarget) { + let Some(direction) = target.direction.try_normalize() else { + return; + }; + if !target.anchor.is_finite() || self.has_target(&target) { + return; + } + target.direction = direction; + self.targets.push(target); + if self.step != PresspullStep::Multiple { + self.step = PresspullStep::Height; + } + self.invalidate_preview(); + } + + fn on_presspull_applied(&mut self, success: bool) { + if success { + self.targets.clear(); + self.step = PresspullStep::Pick; + self.invalidate_preview(); + } else if !self.targets.is_empty() { + self.step = PresspullStep::Height; } } fn wants_text_input(&self) -> bool { - self.picked.is_some() + true + } + + fn point_step_accepts_keywords(&self) -> bool { + self.needs_entity_pick() } fn on_text_input(&mut self, text: &str) -> Option { - let (handle, pick) = self.picked?; - crate::entities::common::parse_typed_length(text) - .filter(|distance| distance.abs() > 1e-6) - .map(|distance| CmdResult::PresspullEntity { - handle, - pick, - distance, - drag: None, - color: self.color, - }) + let text = text.trim(); + if text.is_empty() { + return Some(self.on_enter()); + } + match text.trim_start_matches('_').to_ascii_uppercase().as_str() { + "M" | "MULTIPLE" if !self.targets.is_empty() => { + self.step = PresspullStep::Multiple; + return Some(CmdResult::NeedPoint); + } + "U" | "UNDO" => return Some(self.undo_selection()), + _ => {} + } + // The shared picker resolves coordinates and hexadecimal handles. + // Do not consume either as a distance while selecting targets. + if self.needs_entity_pick() || text.starts_with("0x") || text.starts_with("0X") { + return None; + } + crate::entities::common::parse_typed_length(text).map(|height| self.finish(height)) } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if self.step == PresspullStep::Multiple && !self.targets.is_empty() { + self.step = PresspullStep::Height; + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_undo_step(&mut self) -> Option { + (!self.targets.is_empty()).then(|| self.undo_selection()) } fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { - Some((self.picked?.1, self.direction?)) + if self.needs_entity_pick() { + return None; + } + let target = self.targets.last()?; + Some((target.anchor, target.direction)) + } + + fn resolved_anchor(&self) -> Option { + self.targets.last().map(|target| target.anchor) } fn dyn_spec(&self) -> Option { - let (_, anchor) = self.picked?; + let (anchor, _) = self.cursor_axis()?; Some(crate::command::DynSpec { anchor: crate::command::DynAnchor::Point(anchor), fields: vec![crate::command::DynFieldSpec::new( @@ -629,8 +855,36 @@ impl CadCommand for PresspullCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - let (_, anchor) = self.picked?; - Some((cursor - anchor).dot(self.direction?)) + if self.needs_entity_pick() { + return None; + } + self.height(cursor) + } + + fn dyn_commit_as_text(&self) -> bool { + !self.needs_entity_pick() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if self.needs_entity_pick() { + return Vec::new(); + } + let Some(height) = self.height(cursor).filter(|height| height.abs() > 1e-6) else { + return Vec::new(); + }; + let key = (self.target_generation, height.to_bits()); + if self.preview_key != Some(key) { + self.preview_cache = self.targets.iter().flat_map(|target| { + crate::scene::model::presspull_model::preview_wires( + target, + height, + self.color, + self.isolines, + ) + }).collect(); + self.preview_key = Some(key); + } + self.preview_cache.clone() } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bbb5d3226..dc16aac6b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2861,7 +2861,24 @@ impl Scene { solid: cadkernel::brep::Body, display: (MeshLodSet, Vec, [f64; 3]), ) -> bool { - let (set, wires, center) = display; + let (mut set, wires, center) = display; + // New command results can be prepared before their handle exists. + // Apply the committed entity's actual style without retessellating. + if let Some(entity) = self.document.get_entity(handle) { + let color = self.render_style(entity).0; + for mesh in &mut set.lods { + mesh.name = handle.value().to_string(); + mesh.color = color; + } + crate::scene::model::material_model::resolve_material_with_base( + &self.document, entity, color, None, self.material_base_dir.as_deref(), + ).apply_to_with_face_overrides( + &mut set, &self.document, self.material_base_dir.as_deref(), + ); + crate::scene::model::visual_style_model::apply_mesh_visual_style( + &mut set, &self.document, entity, + ); + } if let Some(entity) = self.document.get_entity_mut(handle) { let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); match entity { diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index a4666abb8..e55a367ba 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -11,6 +11,7 @@ pub mod mesh_model; pub mod solid_model; pub mod solid_history; pub mod sweep_model; +pub mod presspull_model; pub mod loft_command_model; mod sweep_command_model; pub mod visual_style_model; diff --git a/src/scene/model/presspull_model.rs b/src/scene/model/presspull_model.rs new file mode 100644 index 000000000..387a1ca43 --- /dev/null +++ b/src/scene/model/presspull_model.rs @@ -0,0 +1,376 @@ +//! PRESSPULL selection and curve-only previews. Geometry operations stay in the kernel. + +use acadrust::entities::{AcisData, EmbeddedEntity, Region, Wire}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use cadkernel::brep::{self, Body, FaceKey}; +use cadkernel::geom2d::{contains, ring_nesting_depths, signed_area, Curve, Line, Tolerance}; +use cadkernel::space::Plane; +use glam::DVec3; +use rustc_hash::FxHashMap; + +use crate::command::WorkingPlane; +use crate::scene::{boundary_faces, exact_hatch_paths, ring_source_handles, BoundarySource, Scene}; +use super::{sweep_model, wire_model::WireModel}; + +const BOUNDARY_TOLERANCE: f64 = 1e-6; + +#[derive(Clone)] +pub enum PresspullTargetKind { + Profile { entity: EntityType, source: Option, owner: Option }, + Face { handle: Handle, face: FaceKey, body: Body, offset: bool }, +} + +#[derive(Clone)] +pub struct PresspullTarget { + pub kind: PresspullTargetKind, + pub anchor: DVec3, + pub direction: DVec3, +} + +/// Extract exact embedded REGION loops before falling back to polygonal entities. +/// In particular, preview and final extrusion must not approximate REGION arcs +/// from their display wires or discard holes. +pub fn profile_geometry(entity: &EntityType) -> Option<(Plane, Vec>, bool)> { + if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { + return None; + } + let embedded = match entity { + EntityType::Region(region) => Some(( + EmbeddedEntity::Region(region.clone()), glam::DMat4::IDENTITY.to_cols_array(), + )), + _ => sweep_model::embedded_revolve_profile(entity), + }; + if let Some((entity, transform)) = embedded { + if let Ok(geometry) = cadkernel::acis::sweep_profile_geometry(&entity, transform) { + return Some(geometry); + } + // Exact modeler data is authoritative, not a hint to replace by chords. + if matches!(entity, EmbeddedEntity::Region(ref region) if region.acis_data.has_data()) { + return None; + } + } + let (profile, closed) = sweep_model::extrusion_profile_of(entity)?; + Some((profile.plane, vec![profile.pieces], closed)) +} + +/// Builds the final solid or surface without discarding exact REGION loops. +pub fn extrusion_body(entity: &EntityType, direction: [f64; 3]) -> Option { + let (plane, loops, closed) = profile_geometry(entity)?; + if closed { + brep::extrude_region(plane, &loops, direction) + } else if loops.len() == 1 { + brep::extrude_surface(plane, &loops[0], direction) + } else { + None + } +} + +fn eligible(scene: &Scene, entity: &EntityType) -> bool { + let common = entity.common(); + !common.invisible + && !scene.entity_temporarily_hidden(common.handle) + && !scene.layer_hidden(&common.layer) + && !scene.interaction_layer_frozen(&common.layer) + && !scene.is_layer_locked(common.handle) + && scene.belongs_to_visible_block( + common.handle, common.owner_handle, scene.interaction_block_handle(), + ) + && !crate::scene::annotative::annotative_offscale_for( + &scene.document, common, scene.displayed_annotation_scale_handle(), + scene.annotation_all_visible(), + ) +} + +fn working_plane(plane: Plane) -> WorkingPlane { + WorkingPlane::new( + DVec3::from_array(plane.origin), DVec3::from_array(plane.x_axis), + DVec3::from_array(plane.y_axis), + ) +} + +fn curves_on_plane(source: Plane, curves: &[Curve], plane: WorkingPlane) -> Vec { + let origin = plane.to_local(DVec3::from_array(source.origin)); + let x_axis = plane.vector_to_local(DVec3::from_array(source.x_axis)); + let y_axis = plane.vector_to_local(DVec3::from_array(source.y_axis)); + let transform = cadkernel::geom2d::Transform { + origin: [origin.x, origin.y].into(), + x_axis: [x_axis.x, x_axis.y].into(), + y_axis: [y_axis.x, y_axis.y].into(), + }; + curves.iter().filter_map(|curve| { + // A line belongs to more than one plane. Its arbitrary source axes + // must not disqualify an edge which lies in the requested work plane. + if let Curve::Line(line) = curve { + let start = plane.to_local(DVec3::from_array(source.point_at(line.start))); + let end = plane.to_local(DVec3::from_array(source.point_at(line.end))); + return (start.z.abs() <= BOUNDARY_TOLERANCE && end.z.abs() <= BOUNDARY_TOLERANCE) + .then_some(Curve::Line(Line { start: [start.x, start.y], end: [end.x, end.y] })); + } + if origin.z.abs() > BOUNDARY_TOLERANCE || x_axis.z.abs() > 1e-9 || y_axis.z.abs() > 1e-9 { + return None; + } + curve.transformed(&transform) + }).collect() +} + +fn boundary_source(curves: Vec) -> Option { + let curves = curves.into_iter().filter(|curve| { + let length = curve.length(); + length.is_finite() && length > BOUNDARY_TOLERANCE + }).collect::>(); + let segments = curves.iter().flat_map(|curve| { + curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2).filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + (start.iter().chain(&end).all(|value| value.is_finite()) && start != end) + .then_some(Line { start, end }) + }).collect::>() + }).collect::>(); + (!segments.is_empty()).then_some(BoundarySource { segments, curves }) +} + +fn boundary_sources(scene: &Scene, plane: WorkingPlane) -> FxHashMap { + // Read native curve geometry, not shaded meshes, face isolines, temporary + // construction-history overlays or camera-clipped display segments. + scene.document.entities().filter(|entity| eligible(scene, entity)).filter_map(|entity| { + let (source, loops, _) = profile_geometry(entity)?; + let curves = loops.iter().flat_map(|ring| curves_on_plane(source, ring, plane)).collect(); + boundary_source(curves).map(|source| (entity.common().handle, source)) + }).collect() +} + +fn ring_curves(ring: &[[f64; 2]]) -> Vec { + ring.iter().copied().zip(ring.iter().copied().cycle().skip(1)) + .take(ring.len()).map(|(start, end)| Curve::Line(Line { start, end })).collect() +} + +fn selected_rings(sources: &FxHashMap, point: [f64; 2]) -> Option>> { + let rings = boundary_faces(sources, BOUNDARY_TOLERANCE); + let tolerance = Tolerance::new(BOUNDARY_TOLERANCE); + let outer = rings.iter().enumerate() + .filter(|(_, ring)| contains(&ring_curves(ring), point, tolerance)) + .min_by(|(_, left), (_, right)| signed_area(left).abs().total_cmp(&signed_area(right).abs()))? + .0; + let depths = ring_nesting_depths(&rings); + let boundary = ring_curves(&rings[outer]); + let mut result = vec![rings[outer].clone()]; + for (index, ring) in rings.iter().enumerate() { + if index != outer && depths.get(index) == depths.get(outer).map(|depth| depth + 1).as_ref() + && ring.first().is_some_and(|point| contains(&boundary, *point, tolerance)) + { + result.push(ring.clone()); + } + } + Some(result) +} + +fn boundary_region( + sources: &FxHashMap, rings: &[Vec<[f64; 2]>], plane: WorkingPlane, +) -> Option { + let exterior = (0..rings.len()).map(|index| index == 0).collect::>(); + let paths = exact_hatch_paths(rings, &exterior, sources, BOUNDARY_TOLERANCE); + if paths.len() != rings.len() { + return None; + } + let loops = paths.iter().map(|path| { + path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() + }).collect::>>()?; + let kernel_plane = Plane::from_axes(plane.origin.to_array(), plane.x.to_array(), plane.y.to_array()); + let sheet = brep::planar_region(kernel_plane, &loops)?; + let document = crate::scene::convert::acis_export::solid_to_sat(&sheet)?; + let mut region = Region::new(); + region.acis_data = AcisData::from_sat(&document.to_sat_string()); + region.point_of_reference = Vector3::new(plane.origin.x, plane.origin.y, plane.origin.z); + region.wires = loops.iter().map(|ring| { + let mut points = Vec::new(); + for curve in ring { + let skip = usize::from(!points.is_empty()); + points.extend(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .into_iter().skip(skip).map(|point| { + let world = kernel_plane.point_at(point); + Vector3::new(world[0], world[1], world[2]) + })); + } + Wire::from_points(points) + }).collect(); + Some(EntityType::Region(region)) +} + +fn face_at_point(scene: &Scene, point: DVec3, preferred: Option) -> Option<(Handle, FaceKey, &Body)> { + let mut handles = scene.solid_models.keys().copied().collect::>(); + handles.sort_by_key(|handle| handle.value()); + for handle in handles { + if preferred.is_some_and(|preferred| preferred != handle) { + continue; + } + let Some(entity) = scene.document.get_entity(handle) else { continue; }; + if !matches!(entity, EntityType::Solid3D(_)) || !eligible(scene, entity) { + continue; + } + let body = &scene.solid_models[&handle]; + if let Some(face) = brep::planar_face_at_point(body, point.to_array(), BOUNDARY_TOLERANCE) { + return Some((handle, face, body)); + } + } + None +} + +fn profile_owner(scene: &Scene, plane: WorkingPlane, anchor: DVec3) -> Option<(Handle, DVec3)> { + let mut handles = scene.solid_models.keys().copied().collect::>(); + handles.sort_by_key(|handle| handle.value()); + for handle in handles { + let Some(entity) = scene.document.get_entity(handle) else { continue; }; + if !matches!(entity, EntityType::Solid3D(_)) || !eligible(scene, entity) { + continue; + } + let body = &scene.solid_models[&handle]; + for face in body.face_keys() { + let Some(profile) = brep::planar_face_profile(body, face) else { continue; }; + let outward = DVec3::from_array(profile.outward); + if outward.dot(plane.z).abs() < 1.0 - 1e-9 + || profile.plane.distance_to(anchor.to_array())?.abs() > BOUNDARY_TOLERANCE + { + continue; + } + let local = profile.plane.project(anchor.to_array())?; + let boundary = profile.loops.into_iter().flatten().collect::>(); + if contains(&boundary, local, Tolerance::new(BOUNDARY_TOLERANCE)) { + return Some((handle, outward)); + } + } + } + None +} + +/// A supplied curve handle means explicit edge/preselection. An absent handle +/// means a bounded-area pick; closed profiles therefore retain their distinct +/// edge and interior behaviors. A solid handle prefers its exact trimmed face. +pub fn resolve_target( + scene: &Scene, handle: Option, point: DVec3, plane: WorkingPlane, offset: bool, +) -> Result { + if !point.is_finite() { + return Err("PRESSPULL: the selected point is not finite.".to_owned()); + } + if let Some(handle) = handle { + let entity = scene.document.get_entity(handle) + .ok_or("PRESSPULL: the selected object no longer exists.")?; + if !eligible(scene, entity) { + return Err("PRESSPULL: select a visible object on an unlocked layer.".to_owned()); + } + if let Some((source_plane, _, closed)) = profile_geometry(entity) { + let profile_plane = working_plane(source_plane); + let local = profile_plane.to_local(point); + let anchor = profile_plane.to_world(DVec3::new(local.x, local.y, 0.0)); + let attached = closed.then(|| profile_owner(scene, profile_plane, anchor)).flatten(); + let owner = attached.map(|(owner, _)| owner); + let direction = attached.map(|(_, outward)| outward).unwrap_or(profile_plane.z); + return Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity: entity.clone(), source: Some(handle), owner }, + anchor, direction, + }); + } + if !matches!(entity, EntityType::Solid3D(_)) { + return Err("PRESSPULL: select a planar curve, bounded area or planar solid face.".to_owned()); + } + } + + if let Some((owner, face, body)) = face_at_point(scene, point, handle) { + let profile = brep::planar_face_profile(body, face) + .ok_or("PRESSPULL: the selected solid face has no valid planar boundary.")?; + let face_plane = working_plane(profile.plane); + let local = face_plane.to_local(point); + let anchor = face_plane.to_world(DVec3::new(local.x, local.y, 0.0)); + let direction = DVec3::from_array(profile.outward); + if !offset { + let mut sources = boundary_sources(scene, face_plane); + if let Some(source) = boundary_source(profile.loops.into_iter().flatten().collect()) { + sources.insert(owner, source); + } + if let Some(rings) = selected_rings(&sources, [local.x, local.y]) { + let bounded_by_drawing = rings.iter().any(|ring| { + ring_source_handles(ring, &sources).into_iter().any(|source| source != owner) + }); + if bounded_by_drawing { + let entity = boundary_region(&sources, &rings, face_plane) + .ok_or("PRESSPULL: the selected boundary could not be reconstructed exactly.")?; + return Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity, source: None, owner: Some(owner) }, + anchor, direction, + }); + } + } + } + return Ok(PresspullTarget { + kind: PresspullTargetKind::Face { handle: owner, face, body: body.clone(), offset }, + anchor, direction, + }); + } + if handle.is_some() { + return Err("PRESSPULL: select inside a trimmed planar face, outside its holes.".to_owned()); + } + let local = plane.to_local(point); + if local.z.abs() > BOUNDARY_TOLERANCE { + return Err("PRESSPULL: the bounded area is not on the current working plane.".to_owned()); + } + let sources = boundary_sources(scene, plane); + let rings = selected_rings(&sources, [local.x, local.y]) + .ok_or("PRESSPULL: no closed boundary contains the selected point.")?; + let entity = boundary_region(&sources, &rings, plane) + .ok_or("PRESSPULL: the selected boundary could not be reconstructed exactly.")?; + Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity, source: None, owner: None }, + anchor: point, direction: plane.z, + }) +} + +/// Curves only: mouse movement never triangulates a preview body. +pub fn preview_wires(target: &PresspullTarget, distance: f64, color: [f32; 4], isolines: usize) -> Vec { + if !distance.is_finite() || distance.abs() <= 1e-9 { + return Vec::new(); + } + if let PresspullTargetKind::Face { body, face, offset: true, .. } = &target.kind { + // Offset changes the intersections with neighbouring surfaces, so a + // constant-section prism would misrepresent sloped walls and caps. + // The kernel's analytic offset performs no Boolean or triangulation; + // the command memoizes this candidate by selected targets and distance. + // Only return an overlay: failure never hides or edits the resident body. + return brep::presspull_face(body, *face, distance, brep::PresspullMode::Offset) + .map(|candidate| preview_body_wires(&candidate, color, isolines)) + .unwrap_or_default(); + } + let geometry = match &target.kind { + PresspullTargetKind::Profile { entity, .. } => profile_geometry(entity) + .map(|(plane, loops, _)| (plane, loops, (target.direction * distance).to_array())), + PresspullTargetKind::Face { body, face, .. } => { + // The resident source stays visible. Show only the moving face's + // exact extrusion cage; its Boolean merge happens only on commit, + // never inside pointer movement. Unrelated faces and lumps need + // no rebuilding for a normal face-extrusion preview. + brep::planar_face_profile(body, *face).map(|profile| (profile.plane, profile.loops, + (DVec3::from_array(profile.outward) * distance).to_array())) + } + }; + let Some((plane, loops, direction)) = geometry else { return Vec::new(); }; + let mut wires = Vec::new(); + // Each loop is a lateral sheet. Calling extrude_region here would perform + // Boolean differences for holes even though this overlay only needs wires. + for ring in loops { + let ring = brep::extrusion_profile_pieces(&ring); + let Some(body) = brep::extrude_surface(plane, &ring, direction) else { continue; }; + wires.extend(preview_body_wires(&body, color, isolines)); + } + wires +} + +fn preview_body_wires(body: &Body, color: [f32; 4], isolines: usize) -> Vec { + let wireframe = brep::mesh::tessellate_wireframe(body, + brep::mesh::TessellationTolerance::new(cadkernel::tessellation::DEFAULT_ANGLE, 1e-9) + .with_isolines(isolines)); + wireframe.edges.into_iter().map(|edge| edge.positions) + .chain(wireframe.isolines.into_iter().map(|line| line.positions)) + .filter(|points| points.len() >= 2) + .map(|points| WireModel::solid_f64("PRESSPULL-PREVIEW".to_owned(), points, color, false)) + .collect() +} diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 412ca4e8a..37293ac70 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -67,6 +67,9 @@ pub const PROP_TWIST_ALONG_PATH: &str = "solid_history_twist_along_path"; pub const PROP_SCALE_ALONG_PATH: &str = "solid_history_scale_along_path"; pub const PROP_SWEEP_LENGTH: &str = "solid_history_sweep_length"; pub const PROP_EXTRUSION_HEIGHT: &str = "solid_history_extrusion_height"; +pub const PROP_EXTRUSION_DIRECTION_X: &str = "solid_history_extrusion_direction_x"; +pub const PROP_EXTRUSION_DIRECTION_Y: &str = "solid_history_extrusion_direction_y"; +pub const PROP_EXTRUSION_DIRECTION_Z: &str = "solid_history_extrusion_direction_z"; pub const PROP_TAPER_ANGLE: &str = "solid_history_taper_angle"; pub const PROP_REVOLVE_ANGLE: &str = "solid_history_revolve_angle"; pub const PROP_AXIS_POSITION_X: &str = "solid_history_axis_position_x"; @@ -357,15 +360,19 @@ fn extrusion_properties( handle: acadrust::Handle, value: &SolidHistorySweep, ) -> Vec { - let position = world_point( - value.base.transform, - [ - value.reference_point.x, - value.reference_point.y, - value.reference_point.z, - ], - ) - .unwrap_or(glam::DVec3::ZERO); + let direction = matrix(value.base.transform).map(|transform| { + transform.transform_vector3(glam::DVec3::new( + value.direction.x, value.direction.y, value.direction.z, + )) + }).filter(|direction| direction.is_finite()); + let direction_property = |label: &str, field: &'static str, axis: usize| Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(direction.map_or_else( + || t!("Unavailable").into_owned(), + |direction| crate::entities::common::format_length(direction[axis]), + )), + }; let (record_history, object_show_history, show_history_mode) = history_flags(document, handle).unwrap_or((false, false, 1)); let (show_history, show_history_editable) = @@ -391,21 +398,6 @@ fn extrusion_properties( field: "solid_history_type", value: PropValue::ReadOnly(t!("Extrusion").into_owned()), }, - crate::entities::common::edit_prop( - t!("Position X").as_ref(), - PROP_POSITION_X, - position.x, - ), - crate::entities::common::edit_prop( - t!("Position Y").as_ref(), - PROP_POSITION_Y, - position.y, - ), - crate::entities::common::edit_prop( - t!("Position Z").as_ref(), - PROP_POSITION_Z, - position.z, - ), Property { label: t!("Height").into_owned(), field: PROP_EXTRUSION_HEIGHT, @@ -416,6 +408,9 @@ fn extrusion_properties( field: PROP_TAPER_ANGLE, value: taper, }, + direction_property(t!("Direction X").as_ref(), PROP_EXTRUSION_DIRECTION_X, 0), + direction_property(t!("Direction Y").as_ref(), PROP_EXTRUSION_DIRECTION_Y, 1), + direction_property(t!("Direction Z").as_ref(), PROP_EXTRUSION_DIRECTION_Z, 2), ], }, PropSection { @@ -426,7 +421,7 @@ fn extrusion_properties( field: PROP_HISTORY, value: PropValue::Choice { selected: if record_history { "Record" } else { "None" }.to_string(), - options: vec!["None".to_string(), "Record".to_string()], + options: vec!["Record".to_string(), "None".to_string()], }, }, Property { @@ -435,7 +430,7 @@ fn extrusion_properties( value: if show_history_editable { PropValue::Choice { selected: if show_history { "Yes" } else { "No" }.to_string(), - options: vec!["No".to_string(), "Yes".to_string()], + options: vec!["Yes".to_string(), "No".to_string()], } } else { PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) @@ -446,6 +441,43 @@ fn extrusion_properties( ] } +/// Native surface construction data mirrors the same local profile, placement, +/// direction and taper used by the extrusion history rebuild. +pub fn extrusion_surface_data( + value: &SolidHistorySweep, +) -> Option { + use acadrust::entities::{SurfaceData, SurfaceSweepOptions}; + + if value.path_entity.is_some() { + return None; + } + let profile = value.sweep_entity.clone()?; + matrix(value.base.transform)?; + matrix(value.sweep_entity_transform)?; + Some(SurfaceData::Extruded { + sweep_entity: Some(profile), + options: SurfaceSweepOptions { + draft_angle: value.draft_angle, + draft_start_distance: value.start_draft_distance, + draft_end_distance: value.end_draft_distance, + twist_angle: value.twist_angle, + scale_factor: value.scale_factor, + align_angle: value.align_angle, + sweep_entity_transform: value.sweep_entity_transform, + path_entity_transform: value.path_entity_transform, + is_solid: false, + sweep_alignment_flags: value.align_option as i16, + align_start: value.has_align_start, + bank: value.bank, + base_point_set: true, + reference_vector: value.reference_point, + ..SurfaceSweepOptions::default() + }, + sweep_vector: value.direction, + sweep_transform: value.base.transform, + }) +} + fn sweep_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -1439,7 +1471,8 @@ pub fn is_loft_geometry_choice(field: &str) -> bool { pub fn is_specialized_property(field: &str) -> bool { matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH - | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT) + | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT + | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z) || is_primitive_property(field) || is_history_choice(field) } @@ -2977,7 +3010,15 @@ fn apply_extrusion_draft_grip(value: &mut SolidHistorySweep, apply: GripApply) - fn extrusion_draft_rebuilds(value: &SolidHistorySweep, angle: f64) -> bool { let mut candidate = value.clone(); candidate.draft_angle = angle; - cadkernel::acis::rebuild_body(&SolidHistoryOperation::Extrusion(candidate)).is_ok() + let Some(profile) = candidate.sweep_entity.as_ref() else { + return false; + }; + let Ok((_, _, closed)) = cadkernel::acis::sweep_profile_geometry( + profile, candidate.sweep_entity_transform, + ) else { + return false; + }; + cadkernel::acis::rebuild_extrusion_with_mode(&candidate, !closed).is_ok() } fn grip( diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 93a17416b..da76502f0 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -810,14 +810,16 @@ impl Scene { let Some(reference) = reference else { return; }; - let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { - return; - }; - entity.point_of_reference = acadrust::types::Vector3::new( + let point = acadrust::types::Vector3::new( reference.x, reference.y, reference.z, ); + match self.document.get_entity_mut(handle) { + Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point, + Some(EntityType::Surface(entity)) => entity.point_of_reference = point, + _ => {} + } } fn copy_solid_history(&mut self, source: Handle, target: Handle) -> bool { @@ -832,34 +834,74 @@ impl Scene { } pub(crate) fn delete_solid_history(&mut self, handle: Handle) { + if self.is_recording_undo() { + // The codec also clears the entity's history handle. Capture that + // link before deletion so undo reconnects the restored graph. + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } self.record_solid_history_before(handle); self.document.delete_solid_history(handle); } + fn rebuild_history_body( + &self, + handle: Handle, + operation: &acadrust::objects::SolidHistoryOperation, + ) -> Option { + use acadrust::objects::SolidHistoryOperation; + + match (self.document.get_entity(handle)?, operation) { + (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { + cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok() + } + (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) + | (EntityType::Solid3D(_), _) => cadkernel::acis::rebuild_body(operation).ok(), + _ => None, + } + } + + fn history_surface_data( + &self, + handle: Handle, + operation: &acadrust::objects::SolidHistoryOperation, + ) -> Option> { + use acadrust::objects::SolidHistoryOperation; + + match (self.document.get_entity(handle)?, operation) { + (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { + crate::scene::model::solid_history::extrusion_surface_data(value).map(Some) + } + (EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => { + let EntityType::Surface(surface) = + crate::scene::model::loft_command_model::surface_entity(value) + else { return None; }; + Some(Some(surface.surface_data)) + } + (EntityType::Solid3D(_), _) => Some(None), + _ => None, + } + } + pub fn rebuild_solid_history( &mut self, handle: Handle, operation: acadrust::objects::SolidHistoryOperation, ) -> bool { - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; - let loft_surface_data = match (&operation, self.document.get_entity(handle)) { - (acadrust::objects::SolidHistoryOperation::Loft(record), Some(EntityType::Surface(_))) => { - let EntityType::Surface(surface) = crate::scene::model::loft_command_model::surface_entity(record) - else { return false; }; - Some(surface.surface_data) - } - (_, Some(EntityType::Solid3D(_))) => None, - _ => return false, + let Some(surface_data) = self.history_surface_data(handle, &operation) else { + return false; }; let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_)) && !display.0.complete { return false; @@ -876,7 +918,12 @@ impl Scene { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - if let Some(data) = loft_surface_data { entity.surface_data = data; } + if let Some(data) = surface_data { + if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { + entity.kind = acadrust::entities::SurfaceKind::Extruded; + } + entity.surface_data = data; + } } _ => return false, } @@ -889,16 +936,20 @@ impl Scene { let Some(operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; + let Some(surface_data) = self.history_surface_data(handle, &operation) else { + return false; + }; let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_)) && !display.0.complete { return false; @@ -907,10 +958,11 @@ impl Scene { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - if let acadrust::objects::SolidHistoryOperation::Loft(record) = &operation { - if let EntityType::Surface(updated) = crate::scene::model::loft_command_model::surface_entity(record) { - entity.surface_data = updated.surface_data; + if let Some(data) = surface_data { + if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { + entity.kind = acadrust::entities::SurfaceKind::Extruded; } + entity.surface_data = data; } } _ => return false, @@ -925,9 +977,12 @@ impl Scene { handle: Handle, operation: acadrust::objects::SolidHistoryOperation, ) -> bool { - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; + if self.history_surface_data(handle, &operation).is_none() { + return false; + } if self .document .update_solid_history(handle, operation) @@ -935,9 +990,6 @@ impl Scene { { return false; } - if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_) | EntityType::Surface(_))) { - return false; - } self.solid_history_preview_wires.insert( handle, crate::scene::model::solid_model::grip_preview_wires(&body, handle), @@ -960,6 +1012,15 @@ impl Scene { return false; } if let EntityTransform::Translate(delta) = transform { + let surface_data = match (&operation, self.document.get_entity(handle)) { + (acadrust::objects::SolidHistoryOperation::Extrusion(value), Some(EntityType::Surface(_))) => { + let Some(data) = crate::scene::model::solid_history::extrusion_surface_data(value) else { + return false; + }; + Some(data) + } + _ => None, + }; if self .document .update_solid_history(handle, operation) @@ -967,6 +1028,11 @@ impl Scene { { return false; } + if let (Some(data), Some(EntityType::Surface(entity))) = + (surface_data, self.document.get_entity_mut(handle)) + { + entity.surface_data = data; + } self.translate_solid_geometry(handle, delta.to_array()); return true; } From 43c799296708b1c1ead48593e51564bc75b7de1d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:33:31 +0300 Subject: [PATCH 11/16] Use corrected mixed-profile extrusion kernel --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea8fe4f78..871cc5403 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=37a33babe7d64e05d9d4144981eeb9bc8ebf390e#37a33babe7d64e05d9d4144981eeb9bc8ebf390e" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=c7b9fc0425224aaa76e903ae8158f20241d0eaa0#c7b9fc0425224aaa76e903ae8158f20241d0eaa0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index b77d57a61..30abbbe2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "37a33babe7d64e05d9d4144981eeb9bc8ebf390e", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "c7b9fc0425224aaa76e903ae8158f20241d0eaa0", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From c9c5b35d297a63bf283abbaacf64228f87d3b6ed Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:53:15 +0300 Subject: [PATCH 12/16] Retain native PRESSPULL surface data across CAD formats --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- src/scene/model/presspull_model.rs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 871cc5403..64666f0b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=580678d28f11ccbefbce3f9d3390d28ef4bbde80#580678d28f11ccbefbce3f9d3390d28ef4bbde80" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=a362d10de18bd96d50642714096eb792a9c58ba6#a362d10de18bd96d50642714096eb792a9c58ba6" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=c7b9fc0425224aaa76e903ae8158f20241d0eaa0#c7b9fc0425224aaa76e903ae8158f20241d0eaa0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=c484a8ce30369ef503e7639be3071e92201eb76a#c484a8ce30369ef503e7639be3071e92201eb76a" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 30abbbe2e..8e5795859 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "c7b9fc0425224aaa76e903ae8158f20241d0eaa0", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a362d10de18bd96d50642714096eb792a9c58ba6", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "c484a8ce30369ef503e7639be3071e92201eb76a", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "a362d10de18bd96d50642714096eb792a9c58ba6" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/scene/model/presspull_model.rs b/src/scene/model/presspull_model.rs index 387a1ca43..6db93beeb 100644 --- a/src/scene/model/presspull_model.rs +++ b/src/scene/model/presspull_model.rs @@ -354,8 +354,8 @@ pub fn preview_wires(target: &PresspullTarget, distance: f64, color: [f32; 4], i }; let Some((plane, loops, direction)) = geometry else { return Vec::new(); }; let mut wires = Vec::new(); - // Each loop is a lateral sheet. Calling extrude_region here would perform - // Boolean differences for holes even though this overlay only needs wires. + // Each loop is a lateral sheet. This wire-only overlay does not need the + // planar caps or complete solid topology of extrude_region. for ring in loops { let ring = brep::extrusion_profile_pieces(&ring); let Some(body) = brep::extrude_surface(plane, &ring, direction) else { continue; }; From 22e707072da2d723bfe4ec8326ed70f0d5843f57 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:49:19 +0300 Subject: [PATCH 13/16] Update kernel for deep negative PRESSPULL cuts --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64666f0b5..f9e710d47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=c484a8ce30369ef503e7639be3071e92201eb76a#c484a8ce30369ef503e7639be3071e92201eb76a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4fc5e0694567f0719b9b43b52f487ce13cdf6eed#4fc5e0694567f0719b9b43b52f487ce13cdf6eed" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8e5795859..5846651d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a362d10de18bd96d50642714096eb792a9c58ba6", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "c484a8ce30369ef503e7639be3071e92201eb76a", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4fc5e0694567f0719b9b43b52f487ce13cdf6eed", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From eee077cbceef17c1c5707c8c3f1e4d1ef3048867 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:36:20 +0300 Subject: [PATCH 14/16] Use blue for the transient LOFT preview cage --- src/modules/insert/solid3d_cmds.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 26e076108..f068bc8c4 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2063,7 +2063,8 @@ impl LoftCommand { ) { Ok(body) => { // Only edges/isolines: do not triangulate faces on mouse movement. - self.preview_cache = preview_body_wires(&body, self.color, self.isolines); + // Keep the transient cage blue without changing the result's layer color. + self.preview_cache = preview_body_wires(&body, WireModel::SELECTED, self.isolines); } Err(error) => self.preview_error = Some(error), } From d8790ef433c7a3d751b4cf4e58cc4fba84dae580 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 4 Sep 2026 19:34:38 +0300 Subject: [PATCH 15/16] Use exact kernel curves for PRESSPULL area containment --- src/app/presspull_ops.rs | 2 +- src/scene/model/presspull_model.rs | 28 ++++++++++--------- tests/presspull_curved_boundary.rs | 43 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 tests/presspull_curved_boundary.rs diff --git a/src/app/presspull_ops.rs b/src/app/presspull_ops.rs index e9e729946..a2fc64a79 100644 --- a/src/app/presspull_ops.rs +++ b/src/app/presspull_ops.rs @@ -237,7 +237,7 @@ impl super::OpenCADStudio { if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_presspull_applied(true); } - self.command_line.push_output(crate::tf!("PRESSPULL: %{count} object(s) updated.", count = handles.len()).as_ref()); + self.command_line.push_output(crate::tf!("PRESSPULL: {count} object(s) updated.", count = handles.len()).as_ref()); self.refresh_properties(); self.refresh_presspull_prompt(); } diff --git a/src/scene/model/presspull_model.rs b/src/scene/model/presspull_model.rs index 6db93beeb..151b15e95 100644 --- a/src/scene/model/presspull_model.rs +++ b/src/scene/model/presspull_model.rs @@ -141,24 +141,20 @@ fn boundary_sources(scene: &Scene, plane: WorkingPlane) -> FxHashMap Vec { - ring.iter().copied().zip(ring.iter().copied().cycle().skip(1)) - .take(ring.len()).map(|(start, end)| Curve::Line(Line { start, end })).collect() -} - fn selected_rings(sources: &FxHashMap, point: [f64; 2]) -> Option>> { let rings = boundary_faces(sources, BOUNDARY_TOLERANCE); + let loops = boundary_loops(sources, &rings)?; let tolerance = Tolerance::new(BOUNDARY_TOLERANCE); let outer = rings.iter().enumerate() - .filter(|(_, ring)| contains(&ring_curves(ring), point, tolerance)) + .filter(|(index, _)| contains(&loops[*index], point, tolerance)) .min_by(|(_, left), (_, right)| signed_area(left).abs().total_cmp(&signed_area(right).abs()))? .0; let depths = ring_nesting_depths(&rings); - let boundary = ring_curves(&rings[outer]); + let boundary = &loops[outer]; let mut result = vec![rings[outer].clone()]; for (index, ring) in rings.iter().enumerate() { if index != outer && depths.get(index) == depths.get(outer).map(|depth| depth + 1).as_ref() - && ring.first().is_some_and(|point| contains(&boundary, *point, tolerance)) + && ring.first().is_some_and(|point| contains(boundary, *point, tolerance)) { result.push(ring.clone()); } @@ -166,17 +162,23 @@ fn selected_rings(sources: &FxHashMap, point: [f64; 2]) Some(result) } -fn boundary_region( - sources: &FxHashMap, rings: &[Vec<[f64; 2]>], plane: WorkingPlane, -) -> Option { +fn boundary_loops( + sources: &FxHashMap, rings: &[Vec<[f64; 2]>], +) -> Option>> { let exterior = (0..rings.len()).map(|index| index == 0).collect::>(); let paths = exact_hatch_paths(rings, &exterior, sources, BOUNDARY_TOLERANCE); if paths.len() != rings.len() { return None; } - let loops = paths.iter().map(|path| { + paths.iter().map(|path| { path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() - }).collect::>>()?; + }).collect() +} + +fn boundary_region( + sources: &FxHashMap, rings: &[Vec<[f64; 2]>], plane: WorkingPlane, +) -> Option { + let loops = boundary_loops(sources, rings)?; let kernel_plane = Plane::from_axes(plane.origin.to_array(), plane.x.to_array(), plane.y.to_array()); let sheet = brep::planar_region(kernel_plane, &loops)?; let document = crate::scene::convert::acis_export::solid_to_sat(&sheet)?; diff --git a/tests/presspull_curved_boundary.rs b/tests/presspull_curved_boundary.rs new file mode 100644 index 000000000..7bf2d9d15 --- /dev/null +++ b/tests/presspull_curved_boundary.rs @@ -0,0 +1,43 @@ +use acadrust::{entities::Circle, types::Vector3, EntityType}; +use glam::DVec3; +use OpenCADStudio::command::WorkingPlane; +use OpenCADStudio::scene::{model::presspull_model, Scene}; + +#[test] +fn area_picks_follow_exact_curves_and_keep_holes() { + let mut scene = Scene::new(); + for radius in [1000.0, 500.0] { + scene + .document + .add_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::new(0.0, 0.0, 0.0), + radius, + ))) + .unwrap(); + } + // Halfway between display vertices, these points lie beyond the chords. + let angle = std::f64::consts::PI / 48.0; + for (radius, expected_loops) in [(999.0, 2), (499.0, 1)] { + let point = DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0); + let target = + presspull_model::resolve_target(&scene, None, point, WorkingPlane::default(), false) + .expect("point inside the exact circular boundary"); + let presspull_model::PresspullTargetKind::Profile { entity, .. } = target.kind else { + panic!("expected a bounded profile"); + }; + let (_, loops, closed) = presspull_model::profile_geometry(&entity).unwrap(); + assert!(closed); + assert_eq!(loops.len(), expected_loops); + assert!(loops + .iter() + .flatten() + .all(|curve| !matches!(curve, cadkernel::geom2d::Curve::Line(_)))); + let body = presspull_model::extrusion_body(&entity, [0.0, 0.0, 10.0]).unwrap(); + assert!(body.validate().is_empty()); + } + let outside = DVec3::new(1001.0 * angle.cos(), 1001.0 * angle.sin(), 0.0); + assert!( + presspull_model::resolve_target(&scene, None, outside, WorkingPlane::default(), false,) + .is_err() + ); +} From 9026017cd0d9cf50b6d6a324f4f3d864cc2126b5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 4 Sep 2026 19:34:38 +0300 Subject: [PATCH 16/16] Pin reviewed codec and kernel fixes --- Cargo.lock | 4 ++-- Cargo.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9e710d47..0e39fbc44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=a362d10de18bd96d50642714096eb792a9c58ba6#a362d10de18bd96d50642714096eb792a9c58ba6" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4fc5e0694567f0719b9b43b52f487ce13cdf6eed#4fc5e0694567f0719b9b43b52f487ce13cdf6eed" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=67f2427282d28922d9263e37b0136b6632e929b3#67f2427282d28922d9263e37b0136b6632e929b3" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 5846651d5..beea91a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a362d10de18bd96d50642714096eb792a9c58ba6", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4fc5e0694567f0719b9b43b52f487ce13cdf6eed", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "a362d10de18bd96d50642714096eb792a9c58ba6" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies]