From 8bb6703c7064397934e8969198a4941b6bff3d07 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:36:49 +0300 Subject: [PATCH 1/7] Complete FILLETEDGE workflow and history --- Cargo.lock | 4 +- Cargo.toml | 5 +- locales/en-US/opencadstudio.ftl | 11 + locales/tr-TR/opencadstudio.ftl | 11 + src/app/command_driver.rs | 5 +- src/app/commands/dim.rs | 65 +++- src/app/model_ops.rs | 113 +++++- src/app/update/dynamic.rs | 8 + src/app/update/viewport.rs | 8 + src/command.rs | 4 +- src/locale_catalog.rs | 11 + src/modules/model/edge_cmd.rs | 583 ++++++++++++++++++++++++++++--- src/modules/model/mod.rs | 2 +- src/scene/entity.rs | 4 +- src/scene/model/solid_history.rs | 73 +++- src/scene/modify.rs | 65 +++- 16 files changed, 901 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..e4aceb44e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d#1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" +source = "git+https://github.com/ramox81/cadkernel.git?rev=a1286c8fe2223973293c1010c7f8329e4ba0daf5#a1286c8fe2223973293c1010c7f8329e4ba0daf5" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..82788538f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,10 @@ 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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } + +[patch."https://github.com/HakanSeven12/cadkernel.git"] +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "a1286c8fe2223973293c1010c7f8329e4ba0daf5" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6c97312fe..2edae3fdb 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3187,6 +3187,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER Specify axis endpoint: .elliptical = Elliptical .select-a-solid-edge = Select a solid edge: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = Specify fillet radius <__ocs_fmt_0__>: .specify-chamfer-distance = Specify chamfer distance <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID Height = __ocs_fmt_0__, Width = __ocs_fmt_1__, Justification = __ocs_fmt_2__ diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 69705fee8..bbc60a6e3 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3146,6 +3146,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptik .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Bir kenar seçin veya [Zincir/Döngü/Yarıçap]: + .filletedge-select-edge-chain-or-edge-radius = Bir kenar zinciri seçin veya [Kenar/Yarıçap]: + .filletedge-select-loop-edge-or-edge-chain-radius = Döngünün kenarını seçin veya [Kenar/Zincir/Yarıçap]: + .filletedge-enter-option-accept-next = Bir seçenek girin [Kabul/Sonraki] : + .filletedge-accept-or-radius = Fillet'i kabul etmek için Enter'a basın veya [Yarıçap]: + .filletedge-enter-radius-or-expression = Fillet yarıçapını girin veya [İfade] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Yarıçapı belirtin veya [İfade] <__ocs_fmt_0__>: + .filletedge-enter-expression = İfadeyi girin: + .filletedge-stale-edge = Seçilen kenar artık katıya ait değil. + .filletedge-history-create-failed = Katı geçmişi oluşturulamadı. + .filletedge-history-append-failed = Fillet geçmişi kaydedilemedi. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..b0db25725 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -733,6 +733,7 @@ impl OpenCADStudio { &result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..) + | CmdResult::SolidEdgeBlend { .. } | CmdResult::SolidSubtract { .. } ); let task = self.apply_cmd_result_inner(result); @@ -4103,11 +4104,11 @@ impl OpenCADStudio { CmdResult::SolidEdgeBlend { handle, - pick, + edges, value, fillet, } => { - let task = self.solid_edge_blend(handle, pick, value, fillet); + let task = self.solid_edge_blend(handle, &edges, value, fillet); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index b5c2a0eaf..c083f6f60 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -17,6 +17,33 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { } } +fn solid_edge_sources( + app: &mut OpenCADStudio, + tab: usize, +) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> { + let handles = app.tabs[tab] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)) + .then_some(entity.common().handle) + }) + .collect::>(); + app.tabs[tab].scene.restore_solid_models(&handles); + handles + .into_iter() + .filter_map(|handle| { + app.tabs[tab] + .scene + .solid_models + .get(&handle) + .cloned() + .map(|body| (handle, body)) + }) + .collect() +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -960,16 +987,30 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SOLIDFILLET" => { + "FILLETEDGE" | "SOLIDFILLET" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } "FILLET" if selected_solid(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } @@ -1109,14 +1150,28 @@ impl OpenCADStudio { "SOLIDCHAMFER" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Chamfer, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } "CHAMFER" if selected_solid(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Chamfer, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..16e87d6f1 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -7,7 +7,7 @@ use acadrust::{ objects::SolidHistoryOperation, EntityType, Handle, }; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, EdgeKey}; use iced::Task; use std::collections::HashMap; @@ -596,10 +596,92 @@ impl super::OpenCADStudio { true } + fn replace_solid_body_with_fillet( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + radius: f64, + ) -> bool { + let i = self.active_tab; + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&result) else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return false; + }; + 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; + }; + let ordered = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the solid.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "FILLETEDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) + { + self.command_line + .push_error(crate::t!("The solid history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::fillet_op(history_edges, radius), + ) { + self.command_line + .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); + return false; + } + let Some(EntityType::Solid3D(mut entity)) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + return false; + }; + entity.wires = solid_model::edge_wires(&result); + entity.silhouettes.clear(); + entity.set_sat_document(&document); + if !self.tabs[i] + .scene + .update_entity(EntityType::Solid3D(entity)) + { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return false; + } + 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; + self.refresh_properties(); + true + } + pub(super) fn solid_edge_blend( &mut self, handle: Handle, - pick: glam::DVec3, + edges: &[EdgeKey], value: f64, fillet: bool, ) -> Task { @@ -621,15 +703,19 @@ impl super::OpenCADStudio { .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); return Task::none(); }; - let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { - self.command_line - .push_error(crate::t!("Select a solid edge.").as_ref()); - return Task::none(); - }; let result = if fillet { - cadkernel::brep::fillet(&body, edge, value) + match cadkernel::brep::fillet_edges(&body, edges, value) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("FILLETEDGE: {error}")); + return Task::none(); + } + } } else { - cadkernel::brep::chamfer(&body, edge, value) + edges + .first() + .and_then(|edge| cadkernel::brep::chamfer(&body, *edge, value)) }; let Some(result) = result else { self.command_line.push_error( @@ -638,8 +724,13 @@ impl super::OpenCADStudio { ); return Task::none(); }; - let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; - if self.replace_solid_body(handle, result, label) { + let label = if fillet { "FILLETEDGE" } else { "SOLIDCHAMFER" }; + let updated = if fillet { + self.replace_solid_body_with_fillet(handle, &body, result, edges, value) + } else { + self.replace_solid_body(handle, result, label) + }; + if updated { self.command_line .push_output(crate::tf!("{label}: solid updated.").as_ref()); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index feaf6b016..1e1888055 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -740,6 +740,14 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_preview_wires(cur)) .unwrap_or_default(); + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c39916fe4..12c339a01 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2468,6 +2468,14 @@ impl OpenCADStudio { previews.push(guide); } } + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } else { self.tabs[i].snap_result = None; diff --git a/src/command.rs b/src/command.rs index 25ccfb027..f91b124b8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1547,10 +1547,10 @@ pub enum CmdResult { options: LoftOptions, color: [f32; 4], }, - /// Round or bevel the straight edge nearest `pick` on a solid. + /// Round or bevel one or more resolved B-rep edges on a solid. SolidEdgeBlend { handle: Handle, - pick: DVec3, + edges: Vec, value: f64, fillet: bool, }, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index eda54347e..72a2814df 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3871,8 +3871,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "CYLINDER Specify axis endpoint:" => Some(("model", "cylinder-specify-axis-endpoint")), "Elliptical" => Some(("model", "elliptical")), "Select a solid edge:" => Some(("model", "select-a-solid-edge")), + "Select an edge or [Chain/Loop/Radius]:" => Some(("model", "filletedge-select-edge-or-chain-loop-radius")), + "Select an edge chain or [Edge/Radius]:" => Some(("model", "filletedge-select-edge-chain-or-edge-radius")), + "Select edge of loop or [Edge/Chain/Radius]:" => Some(("model", "filletedge-select-loop-edge-or-edge-chain-radius")), + "Enter an option [Accept/Next] :" => Some(("model", "filletedge-enter-option-accept-next")), + "Press Enter to accept the fillet or [Radius]:" => Some(("model", "filletedge-accept-or-radius")), + "Enter fillet radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-enter-radius-or-expression")), + "Specify Radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-specify-radius-or-expression")), + "Enter expression:" => Some(("model", "filletedge-enter-expression")), "Specify fillet radius <{:.3}>:" => Some(("model", "specify-fillet-radius")), "Specify chamfer distance <{:.3}>:" => Some(("model", "specify-chamfer-distance")), + "A selected edge no longer belongs to the solid." => Some(("model", "filletedge-stale-edge")), + "The solid history could not be created." => Some(("model", "filletedge-history-create-failed")), + "The fillet history could not be recorded." => Some(("model", "filletedge-history-append-failed")), "POLYSOLID Height = {:.4}, Width = {:.4}, Justification = {}\n{}" => Some(("model", "polysolid-height-width-justification")), "Specify start point or [Object/Height/Width/Justify] :" => Some(("model", "specify-start-point-or-object-height-width-justify-object")), "Specify height <{:.4}>:" => Some(("model", "specify-height")), diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index 65ee2d315..a88e2fc88 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -1,42 +1,232 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use acadrust::Handle; +use cadkernel::brep::{Body, EdgeKey}; use glam::DVec3; use crate::command::{ - CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, + CadCommand, CmdOption, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, }; +use crate::scene::model::wire_model::WireModel; -#[derive(Clone, Copy)] +static FILLET_RADIUS_BITS: AtomicU64 = AtomicU64::new(1.0f64.to_bits()); + +fn fillet_radius() -> f64 { + f64::from_bits(FILLET_RADIUS_BITS.load(Ordering::Relaxed)) +} + +fn set_fillet_radius(value: f64) { + FILLET_RADIUS_BITS.store(value.to_bits(), Ordering::Relaxed); +} + +#[derive(Clone, Copy, PartialEq, Eq)] pub enum EdgeOperation { Fillet, Chamfer, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeSelectionMode { + Edge, + Chain, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeStep { + Selecting, + PickingLoop, + LoopConfirm, + PreviewConfirm, + Radius, + Expression, + ChamferValue, +} + pub struct SolidEdgeCommand { operation: EdgeOperation, target: Option, - picked: Option<(Handle, DVec3)>, + bodies: Vec<(Handle, Body)>, + handle: Option, + selected_edges: Vec, + selection_batches: Vec<(Vec, DVec3)>, + selection_mode: EdgeSelectionMode, + step: EdgeStep, + radius_return: EdgeStep, + loop_candidates: Vec>, + loop_index: usize, + last_pick: Option, default_value: f64, + preview_color: [f32; 4], + preview_wires: Vec, + preview_hidden: Vec, } impl SolidEdgeCommand { - pub fn new(operation: EdgeOperation, target: Option) -> Self { + pub fn new( + operation: EdgeOperation, + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + ) -> Self { Self { operation, target, - picked: None, - default_value: 1.0, + bodies, + handle: None, + selected_edges: Vec::new(), + selection_batches: Vec::new(), + selection_mode: EdgeSelectionMode::Edge, + step: EdgeStep::Selecting, + radius_return: EdgeStep::Selecting, + loop_candidates: Vec::new(), + loop_index: 0, + last_pick: None, + default_value: if operation == EdgeOperation::Fillet { + fillet_radius() + } else { + 1.0 + }, + preview_color, + preview_wires: Vec::new(), + preview_hidden: Vec::new(), } } - fn finish(&self, value: f64) -> CmdResult { - let Some((handle, pick)) = self.picked else { + fn body(&self, handle: Handle) -> Option<&Body> { + self.bodies + .iter() + .find_map(|(candidate, body)| (*candidate == handle).then_some(body)) + } + + fn active_body(&self) -> Option<(Handle, &Body)> { + let handle = self.handle?; + self.body(handle).map(|body| (handle, body)) + } + + fn pick_allowed(&self, handle: Handle) -> bool { + !handle.is_null() + && self.target.is_none_or(|target| target == handle) + && self.handle.is_none_or(|selected| selected == handle) + && self.body(handle).is_some() + } + + fn add_batch(&mut self, edges: Vec, anchor: DVec3) -> bool { + let batch = edges + .into_iter() + .filter(|edge| !self.selected_edges.contains(edge)) + .collect::>(); + if batch.is_empty() { + return false; + } + self.selected_edges.extend(batch.iter().copied()); + self.selection_batches.push((batch, anchor)); + true + } + + fn current_loop(&self) -> Option<&[EdgeKey]> { + self.loop_candidates + .get(self.loop_index) + .map(Vec::as_slice) + } + + fn preview_edges(&self) -> Vec { + let mut edges = self.selected_edges.clone(); + if self.step == EdgeStep::LoopConfirm { + if let Some(loop_edges) = self.current_loop() { + for edge in loop_edges { + if !edges.contains(edge) { + edges.push(*edge); + } + } + } + } + edges + } + + fn preview_active(&self) -> bool { + self.operation == EdgeOperation::Fillet + && matches!( + self.step, + EdgeStep::LoopConfirm + | EdgeStep::PreviewConfirm + | EdgeStep::Radius + | EdgeStep::Expression + ) + } + + fn preview_for_value(&self, value: f64) -> Option<(Handle, Vec)> { + if !self.preview_active() { + return None; + } + let edges = self.preview_edges(); + if edges.is_empty() { + return None; + } + let (handle, body) = self.active_body()?; + let result = cadkernel::brep::fillet_edges(body, &edges, value).ok()?; + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + for wire in &mut wires { + wire.color = self.preview_color; + wire.name = format!("{}-FILLETEDGE-PREVIEW", handle.value()); + } + (!wires.is_empty()).then_some((handle, wires)) + } + + fn rebuild_preview(&mut self) { + self.preview_wires.clear(); + self.preview_hidden.clear(); + if let Some((handle, wires)) = self.preview_for_value(self.default_value) { + self.preview_wires = wires; + self.preview_hidden.push(handle); + } + } + + fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult { + self.radius_return = return_to; + self.step = EdgeStep::Radius; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_radius(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + self.default_value = value; + if self.operation == EdgeOperation::Fillet { + set_fillet_radius(value); + } + self.step = self.radius_return; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn accept_loop(&mut self) -> CmdResult { + if let (Some(edges), Some(anchor)) = ( + self.current_loop().map(|edges| edges.to_vec()), + self.last_pick, + ) { + self.add_batch(edges, anchor); + } + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn finish(&self) -> CmdResult { + let Some(handle) = self.handle else { return CmdResult::Cancel; }; + if self.selected_edges.is_empty() { + return CmdResult::Cancel; + } CmdResult::SolidEdgeBlend { handle, - pick, - value, - fillet: matches!(self.operation, EdgeOperation::Fillet), + edges: self.selected_edges.clone(), + value: self.default_value, + fillet: self.operation == EdgeOperation::Fillet, } } } @@ -44,27 +234,83 @@ impl SolidEdgeCommand { impl CadCommand for SolidEdgeCommand { fn name(&self) -> &'static str { match self.operation { - EdgeOperation::Fillet => "SOLIDFILLET", + EdgeOperation::Fillet => "FILLETEDGE", EdgeOperation::Chamfer => "SOLIDCHAMFER", } } fn prompt(&self) -> String { - if self.picked.is_none() { - return crate::t!("Select a solid edge:").into_owned(); + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => { + crate::t!("Select a solid edge:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::ChamferValue, _) => crate::tf!( + "Specify chamfer distance <{:.3}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => { + crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned() + } + (_, EdgeStep::Selecting, EdgeSelectionMode::Chain) => { + crate::t!("Select an edge chain or [Edge/Radius]:").into_owned() + } + (_, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Chain/Radius]:").into_owned() + } + (_, EdgeStep::LoopConfirm, _) => { + crate::t!("Enter an option [Accept/Next] :").into_owned() + } + (_, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned() + } + (_, EdgeStep::Radius, _) if self.radius_return == EdgeStep::PreviewConfirm => crate::tf!( + "Specify Radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Radius, _) => crate::tf!( + "Enter fillet radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Expression, _) => crate::t!("Enter expression:").into_owned(), + _ => String::new(), } - match self.operation { - EdgeOperation::Fillet => { - crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + + fn options(&self) -> Vec { + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![ + CmdOption::new("Chain", "C"), + CmdOption::new("Loop", "L"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Chain) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Chain", "C"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Fillet, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Radius", "R")] } - EdgeOperation::Chamfer => { - crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + (EdgeOperation::Fillet, EdgeStep::Radius, _) => { + vec![CmdOption::new("Expression", "E")] } + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.picked.is_none() + matches!(self.step, EdgeStep::Selecting | EdgeStep::PickingLoop) } fn entity_pick_includes_fills(&self) -> bool { @@ -80,52 +326,199 @@ impl CadCommand for SolidEdgeCommand { } fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { - if handle.is_null() || self.target.is_some_and(|target| target != handle) { + if !self.pick_allowed(handle) { return CmdResult::NeedPoint; } - self.picked = Some((handle, point)); + let Some(seed) = self + .body(handle) + .and_then(|body| crate::scene::model::solid_model::nearest_edge(body, point.to_array())) + else { + return CmdResult::NeedPoint; + }; + self.handle = Some(handle); + self.last_pick = Some(point); + + if self.operation == EdgeOperation::Chamfer { + self.add_batch(vec![seed], point); + self.step = EdgeStep::ChamferValue; + return CmdResult::NeedPoint; + } + + if self.step == EdgeStep::PickingLoop { + self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + self.loop_index = 0; + self.step = if self.loop_candidates.is_empty() { + EdgeStep::Selecting + } else { + EdgeStep::LoopConfirm + }; + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + + let edges = match self.selection_mode { + EdgeSelectionMode::Edge => vec![seed], + EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed), + }; + self.add_batch(edges, point); CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { - let Some((_, pick)) = self.picked else { + if !matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) { + return CmdResult::NeedPoint; + } + let Some(anchor) = self.last_pick else { return CmdResult::NeedPoint; }; - let value = point.distance(pick); - if value > 0.0 && value.is_finite() { - self.finish(value) + let value = point.distance(anchor); + if value <= 0.0 || !value.is_finite() { + return CmdResult::NeedPoint; + } + if self.step == EdgeStep::ChamferValue { + self.default_value = value; + self.finish() } else { - CmdResult::NeedPoint + self.accept_radius(value).unwrap_or(CmdResult::NeedPoint) } } fn on_text_input(&mut self, text: &str) -> Option { - if self.picked.is_none() { - return None; + let keyword = text.trim().to_ascii_uppercase(); + if self.operation == EdgeOperation::Chamfer { + if self.step != EdgeStep::ChamferValue { + return None; + } + let value = keyword.parse::().ok()?; + if value <= 0.0 || !value.is_finite() { + return None; + } + self.default_value = value; + return Some(self.finish()); + } + + match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::Selecting)), + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + Some(CmdResult::NeedPoint) + } + "E" | "EDGE" if self.selection_mode == EdgeSelectionMode::Chain => { + self.selection_mode = EdgeSelectionMode::Edge; + Some(CmdResult::NeedPoint) + } + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.selection_mode = EdgeSelectionMode::Edge; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::Radius => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.step = EdgeStep::Expression; + return Some(CmdResult::NeedPoint); + } + let value = keyword.parse::().ok()?; + self.accept_radius(value) + } + EdgeStep::Expression => { + let value = keyword.parse::().ok()?; + self.accept_radius(value) + } + EdgeStep::ChamferValue => None, } - let value = text.trim().parse::().ok()?; - (value > 0.0 && value.is_finite()).then(|| self.finish(value)) } fn on_enter(&mut self) -> CmdResult { - if self.picked.is_some() { - self.finish(self.default_value) - } else { - CmdResult::Cancel + match (self.operation, self.step) { + (EdgeOperation::Chamfer, EdgeStep::ChamferValue) => self.finish(), + (EdgeOperation::Chamfer, _) => CmdResult::Cancel, + (_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (_, EdgeStep::LoopConfirm) => self.accept_loop(), + (_, EdgeStep::PreviewConfirm) => self.finish(), + (_, EdgeStep::Radius) => self + .accept_radius(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (_, EdgeStep::Expression) => CmdResult::NeedPoint, + _ => CmdResult::Cancel, } } + fn on_undo_step(&mut self) -> Option { + if self.operation != EdgeOperation::Fillet { + return None; + } + if self.step == EdgeStep::LoopConfirm { + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + return Some(CmdResult::NeedPoint); + } + let (batch, _) = self.selection_batches.pop()?; + for edge in batch { + if let Some(index) = self.selected_edges.iter().position(|candidate| *candidate == edge) { + self.selected_edges.remove(index); + } + } + if self.selected_edges.is_empty() { + self.handle = None; + } + self.last_pick = self + .selection_batches + .last() + .map(|(_, anchor)| *anchor); + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + fn wants_text_input(&self) -> bool { - self.picked.is_some() + matches!( + self.step, + EdgeStep::Radius | EdgeStep::Expression | EdgeStep::ChamferValue + ) } fn dyn_commit_as_text(&self) -> bool { - self.picked.is_some() + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) } fn dyn_spec(&self) -> Option { - let (_, pick) = self.picked?; - Some(DynSpec { + let pick = self.last_pick?; + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue).then(|| DynSpec { anchor: DynAnchor::Point(pick), fields: vec![DynFieldSpec::new(match self.operation { EdgeOperation::Fillet => DynRole::Radius, @@ -137,10 +530,118 @@ impl CadCommand for SolidEdgeCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - self.picked.map(|(_, pick)| cursor.distance(pick)) + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + .then(|| self.last_pick.map(|pick| cursor.distance(pick))) + .flatten() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if self.step == EdgeStep::Radius { + if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { + if value > 0.0 && value.is_finite() { + if let Some((handle, wires)) = self.preview_for_value(value) { + self.preview_wires = wires; + self.preview_hidden.clear(); + self.preview_hidden.push(handle); + } else { + self.preview_wires.clear(); + self.preview_hidden.clear(); + } + } + } + } + self.preview_wires.clone() + } + + fn preview_hidden_handles(&self) -> &[Handle] { + &self.preview_hidden } } +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { + let Some(edge) = body.edges.get(seed) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + for coedge_key in &edge.coedges { + let Some(coedge) = body.coedges.get(*coedge_key) else { + continue; + }; + let Some(edge_loop) = body.loops.get(coedge.owner) else { + continue; + }; + let edges = edge_loop + .coedges + .iter() + .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) + .collect::>(); + if !edges.is_empty() && !candidates.contains(&edges) { + candidates.push(edges); + } + } + candidates +} + +fn edge_chain(body: &Body, seed: EdgeKey) -> Vec { + let mut chain = vec![seed]; + let mut next = 0; + while next < chain.len() { + let current_key = chain[next]; + next += 1; + let Some(current) = body.edges.get(current_key) else { + continue; + }; + for vertex in [current.start, current.end] { + let Some(current_tangent) = edge_tangent_from_vertex(body, current_key, vertex) else { + continue; + }; + for candidate_key in body.edge_keys() { + if chain.contains(&candidate_key) { + continue; + } + let Some(candidate) = body.edges.get(candidate_key) else { + continue; + }; + if candidate.start != vertex && candidate.end != vertex { + continue; + } + let Some(candidate_tangent) = + edge_tangent_from_vertex(body, candidate_key, vertex) + else { + continue; + }; + if current_tangent.dot(candidate_tangent) < -0.999 { + chain.push(candidate_key); + } + } + } + } + chain +} + +fn edge_tangent_from_vertex( + body: &Body, + edge_key: EdgeKey, + vertex: cadkernel::brep::VertexKey, +) -> Option { + let edge = body.edges.get(edge_key)?; + let curve = body.curves.get(edge.curve)?; + let span = edge.end_parameter - edge.start_parameter; + if !span.is_finite() || span.abs() <= f64::EPSILON { + return None; + } + let step = span * 1.0e-6; + let (at, inside) = if edge.start == vertex { + (edge.start_parameter, edge.start_parameter + step) + } else if edge.end == vertex { + (edge.end_parameter, edge.end_parameter - step) + } else { + return None; + }; + let tangent = DVec3::from(curve.point_at(inside)) - DVec3::from(curve.point_at(at)); + (tangent.length_squared() > 0.0 && tangent.is_finite()).then(|| tangent.normalize()) +} + inventory::submit!(crate::command::CommandRegistration { - names: &["SOLIDFILLET", "SOLIDCHAMFER"] + names: &["FILLETEDGE", "SOLIDFILLET", "SOLIDCHAMFER"] }); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..72699a0b7 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -88,7 +88,7 @@ impl CadModule for ModelModule { RibbonGroup { title: "Edges", tools: vec![ - RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), + RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), ], }, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a1dbbf3af..ca4609047 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -596,8 +596,8 @@ impl Scene { .filter_map(|&handle| { let from_history = self .document - .solid_history_operation(handle) - .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + .solid_history_operations(handle) + .and_then(|operations| cadkernel::acis::rebuild_history(&operations).ok()); let body = from_history.or_else(|| match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index c52d28732..0e86cd324 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,12 +1,12 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; use acadrust::EntityType; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, Surface}; use crate::command::EntityTransform; use crate::entities::traits::EntityTypeOps; @@ -32,6 +32,7 @@ pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000; pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; +pub const GRIP_FILLET_RADIUS: usize = 10_014; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -3086,6 +3087,51 @@ fn grip( } } +pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option { + if !radius.is_finite() || radius <= 0.0 { + return None; + } + for (face_key, face) in body.faces.iter() { + let Some(Surface::Cylinder(cylinder)) = body.surfaces.get(face.surface) else { + continue; + }; + if (cylinder.radius.abs() - radius).abs() > radius.max(1.0) * 1.0e-7 { + continue; + } + let axis = glam::DVec3::from_array(cylinder.base.normal()?).try_normalize()?; + let origin = glam::DVec3::from_array(cylinder.base.origin); + let points = body + .face_coedges(face_key) + .into_iter() + .filter_map(|coedge| body.coedges.get(coedge)) + .filter_map(|coedge| body.edges.get(coedge.edge)) + .flat_map(|edge| [edge.start, edge.end]) + .filter_map(|vertex| body.vertices.get(vertex)) + .map(|vertex| glam::DVec3::from_array(vertex.point)) + .collect::>(); + let minimum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::min)?; + let maximum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::max)?; + let center = origin + axis * ((minimum + maximum) * 0.5); + let radial = points + .iter() + .map(|point| *point - center - axis * (*point - center).dot(axis)) + .find_map(|radial| radial.try_normalize())?; + return Some(grip( + GRIP_FILLET_RADIUS, + center + radial * radius, + GripShape::Square, + Some(radial), + )); + } + None +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3093,6 +3139,18 @@ pub fn primitive_grips( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; + if let SolidHistoryOperation::Fillet(value) = operation { + let Some(radius) = value.radii.first().copied() else { + return Vec::new(); + }; + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { + return Vec::new(); + }; + return crate::scene::convert::solid3d_tess::kernel_body(solid) + .and_then(|body| fillet_radius_grip(&body, radius)) + .into_iter() + .collect(); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3713,3 +3771,14 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } + +pub fn fillet_op(edges: Vec, radius: f64) -> SolidHistoryOperation { + SolidHistoryOperation::Fillet(SolidHistoryFillet { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + edges, + radii: vec![radius], + ..SolidHistoryFillet::default() + }) +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index f84605425..47dc462ca 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -802,6 +802,29 @@ impl Scene { true } + pub fn append_solid_history( + &mut self, + handle: Handle, + operation: acadrust::objects::SolidHistoryOperation, + ) -> bool { + let previous = self + .document + .solid_history_graph(handle) + .map(|graph| graph.nodes) + .unwrap_or_default(); + self.record_solid_history_before(handle); + let Some(graph) = self.document.append_solid_history(handle, operation) else { + return false; + }; + for node in graph.nodes { + if !previous.contains(&node) { + self.record_undo_object_before(node, None); + } + } + self.sync_solid_reference_point(handle); + true + } + pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document @@ -855,8 +878,14 @@ impl Scene { (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(), + (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) => { + cadkernel::acis::rebuild_body(operation).ok() + } + (EntityType::Solid3D(_), _) => { + let mut operations = self.document.solid_history_operations(handle)?; + *operations.last_mut()? = operation.clone(); + cadkernel::acis::rebuild_history(&operations).ok() + } _ => None, } } @@ -1122,6 +1151,38 @@ impl Scene { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; + if grip_id == crate::scene::model::solid_history::GRIP_FILLET_RADIUS { + let acadrust::objects::SolidHistoryOperation::Fillet(value) = &mut operation else { + return false; + }; + let Some(radius) = value.radii.first().copied() else { + return false; + }; + self.restore_solid_models(&[handle]); + let Some(definition) = self + .solid_models + .get(&handle) + .and_then(|body| { + crate::scene::model::solid_history::fillet_radius_grip(body, radius) + }) + else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let radius = radius + change; + if !radius.is_finite() || radius <= 1.0e-6 { + return false; + } + value.radii.clear(); + value.radii.push(radius); + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, From d426012660ea7559343ee6bf674f00415bb398ef Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:52:18 +0300 Subject: [PATCH 2/7] Execute command scripts through full input runner --- src/app/commands/fileops.rs | 10 ++++------ src/app/mod.rs | 6 +++++- src/app/update/mod.rs | 8 ++++++++ src/main.rs | 4 +++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 5b68856ea..e1237595b 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -335,9 +335,9 @@ impl OpenCADStudio { "PERSP" => return Some(Task::done(Message::SetProjection(false))), "LAYERS" => return Some(Task::done(Message::ToggleLayers)), - // SCRIPT — run a command script: each non-blank, non-comment + // SCRIPT — run a command script: each non-comment // line is fed through the same command path the `--script` startup - // flag uses, so the behaviour matches headless automation exactly. + // flag uses. Blank rows submit Enter to the active command. "SCRIPT" | "SCR" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); @@ -352,10 +352,8 @@ impl OpenCADStudio { let cmds: Vec> = text .lines() .map(str::trim) - .filter(|l| { - !l.is_empty() && !l.starts_with('#') && !l.starts_with(';') - }) - .map(|l| Task::done(Message::Command(l.to_string()))) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .map(|l| Task::done(Message::ScriptLine(l.to_string()))) .collect(); self.command_line.push_output(crate::tf!( "SCRIPT: running {} command(s) from {p}.", diff --git a/src/app/mod.rs b/src/app/mod.rs index d201787c3..c9dbd3cbf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2117,6 +2117,10 @@ pub enum Message { CommandInput(String), CommandSubmit, Command(String), + /// Execute one complete line read from a command script. Unlike UI/ribbon + /// dispatch, this accepts an interactive verb and all of its arguments on + /// the same line (`BOX 0,0,0 10,10,0 10`). + ScriptLine(String), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) @@ -3832,7 +3836,7 @@ impl OpenCADStudio { Task::batch( cfg.script_lines .into_iter() - .map(|line| Task::done(Message::Command(line))), + .map(|line| Task::done(Message::ScriptLine(line))), ) }; s.queue_startup_prompts(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b80f9ee7c..ed34e2c6e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1953,6 +1953,14 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } + Message::ScriptLine(line) => { + if line.trim().is_empty() { + self.feed_command(crate::command::StepInput::Enter) + } else { + self.run_command_line(&line) + } + } + Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) { self.ribbon.deactivate_tool_if("LAYERS"); diff --git a/src/main.rs b/src/main.rs index f197aa6f9..a691981e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,7 +134,9 @@ fn main() -> iced::Result { Ok(text) => text .lines() .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) + // Blank script rows are significant: they submit Enter to + // the active command (for example, accepting a preview). + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) .map(str::to_string) .collect(), Err(e) => { From 53bd85d8a0f2401edc774b37a878e17bca5021b8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:00:22 +0300 Subject: [PATCH 3/7] Use kernel corner and analytic profile fillets --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4aceb44e..7006b3144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=a1286c8fe2223973293c1010c7f8329e4ba0daf5#a1286c8fe2223973293c1010c7f8329e4ba0daf5" +source = "git+https://github.com/ramox81/cadkernel.git?rev=31b19e69ab6edced5f3563c9a12c974268dcb8b0#31b19e69ab6edced5f3563c9a12c974268dcb8b0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 82788538f..9e9ac910e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "a1286c8fe2223973293c1010c7f8329e4ba0daf5" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "31b19e69ab6edced5f3563c9a12c974268dcb8b0" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From ed8cddc5566a616bc5c94562c33972a218413087 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:16:30 +0300 Subject: [PATCH 4/7] Pin analytic pcurve serialization and spherical corner fixes --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7006b3144..b85f7a77b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/ramox81/cadcodec.git?rev=1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d#1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" +source = "git+https://github.com/ramox81/cadcodec.git?rev=af1f31287d4730f228cbb27d6884078295627ca7#af1f31287d4730f228cbb27d6884078295627ca7" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=31b19e69ab6edced5f3563c9a12c974268dcb8b0#31b19e69ab6edced5f3563c9a12c974268dcb8b0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536#f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 9e9ac910e..3fb177f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,10 +74,10 @@ 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://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "31b19e69ab6edced5f3563c9a12c974268dcb8b0" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 8822371e18cf524c3962a82bb31bbf04f3d311d1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:45:10 +0300 Subject: [PATCH 5/7] Complete CHAMFEREDGE workflow and history --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 11 +- src/app/commands/dim.rs | 57 +++-- src/app/model_ops.rs | 231 +++++++++++++------- src/command.rs | 2 + src/modules/model/edge_cmd.rs | 361 +++++++++++++++++++++++++------ src/modules/model/mod.rs | 2 +- src/scene/model/solid_history.rs | 126 ++++++++++- src/scene/modify.rs | 41 ++++ 10 files changed, 678 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b85f7a77b..c45a8f9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536#f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" +source = "git+https://github.com/ramox81/cadkernel.git?rev=2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4#2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3fb177f53..1d8f7725b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b0db25725..128dad867 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4105,10 +4105,19 @@ impl OpenCADStudio { CmdResult::SolidEdgeBlend { handle, edges, + base_face, value, + other_value, fillet, } => { - let task = self.solid_edge_blend(handle, &edges, value, fillet); + let task = self.solid_edge_blend( + handle, + &edges, + base_face, + value, + other_value, + fillet, + ); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index c083f6f60..8adf3b441 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,6 +1,6 @@ use super::*; -fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { +fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option { let scene = &app.tabs.get(tab)?.scene; let selected = scene.selected_handles_in_order(); let [handle] = selected.as_slice() else { @@ -9,7 +9,7 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { if matches!( scene.document.get_entity(*handle), - Some(acadrust::EntityType::Solid3D(_)) + Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)) ) { Some(*handle) } else { @@ -26,7 +26,10 @@ fn solid_edge_sources( .document .entities() .filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)) + matches!( + entity, + acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_) + ) .then_some(entity.common().handle) }) .collect::>(); @@ -989,7 +992,7 @@ impl OpenCADStudio { "FILLETEDGE" | "SOLIDFILLET" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + let target = selected_edge_body(self, i); let bodies = solid_edge_sources(self, i); let command = SolidEdgeCommand::new( EdgeOperation::Fillet, @@ -1001,9 +1004,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); } - "FILLET" if selected_solid(self, i).is_some() => { + "FILLET" if selected_edge_body(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + let target = selected_edge_body(self, i); let bodies = solid_edge_sources(self, i); let command = SolidEdgeCommand::new( EdgeOperation::Fillet, @@ -1148,29 +1151,51 @@ impl OpenCADStudio { } } - "SOLIDCHAMFER" => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + "CHAMFEREDGE" | "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); let bodies = solid_edge_sources(self, i); - let command = SolidEdgeCommand::new( - EdgeOperation::Chamfer, + let command = SolidEdgeCommand::new_chamfer( target, bodies, crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "CHAMFER" if selected_solid(self, i).is_some() => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + "CHAMFER" if selected_edge_body(self, i).is_some() => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); let bodies = solid_edge_sources(self, i); - let command = SolidEdgeCommand::new( - EdgeOperation::Chamfer, + let command = SolidEdgeCommand::new_chamfer( target, bodies, crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 16e87d6f1..17c39b765 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -7,7 +7,7 @@ use acadrust::{ objects::SolidHistoryOperation, EntityType, Handle, }; -use cadkernel::brep::{Body, EdgeKey}; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use iced::Task; use std::collections::HashMap; @@ -244,6 +244,29 @@ fn entity_with_boolean_body(mut source: EntityType, body: &Body) -> Option Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + if entity.kind != SurfaceKind::Plane { + entity.kind = SurfaceKind::Generic; + entity.surface_data = SurfaceData::Generic; + } + entity.acis_data = AcisData::from_sat(&document.to_sat_string()); + } + _ => return None, + } + Some(source) +} + enum IntersectBodyOutcome { Area(Body), Touching, @@ -551,51 +574,6 @@ impl super::OpenCADStudio { groups } - fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { - let i = self.active_tab; - let Some(document) = - crate::scene::convert::acis_export::solid_to_sat(&result) - else { - self.command_line - .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); - return false; - }; - let Some(EntityType::Solid3D(mut entity)) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { - return false; - }; - entity.wires = solid_model::edge_wires(&result); - entity.silhouettes.clear(); - 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] - .scene - .update_entity(EntityType::Solid3D(entity)) - { - self.command_line - .push_error(crate::t!("The solid could not be updated.").as_ref()); - return false; - } - let history = solid_history::brep_op(&result); - self.tabs[i].scene.create_solid_history(handle, history); - 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; - self.refresh_properties(); - true - } - fn replace_solid_body_with_fillet( &mut self, handle: Handle, @@ -605,17 +583,12 @@ impl super::OpenCADStudio { radius: f64, ) -> bool { let i = self.active_tab; - let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&result) else { - self.command_line - .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); - return false; - }; 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()); + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); return false; }; let ordered = source.edge_keys().collect::>(); @@ -630,7 +603,7 @@ impl super::OpenCADStudio { .collect::>>() else { self.command_line - .push_error(crate::t!("A selected edge no longer belongs to the solid.").as_ref()); + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); return false; }; @@ -641,7 +614,7 @@ impl super::OpenCADStudio { .create_solid_history(handle, solid_history::brep_op(source)) { self.command_line - .push_error(crate::t!("The solid history could not be created.").as_ref()); + .push_error(crate::t!("The edge body history could not be created.").as_ref()); return false; } if !self.tabs[i].scene.append_solid_history( @@ -652,20 +625,103 @@ impl super::OpenCADStudio { .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); return false; } - let Some(EntityType::Solid3D(mut entity)) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; - entity.wires = solid_model::edge_wires(&result); - entity.silhouettes.clear(); - entity.set_sat_document(&document); - if !self.tabs[i] + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); + return false; + } + self.tabs[i] .scene - .update_entity(EntityType::Solid3D(entity)) + .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; + self.refresh_properties(); + true + } + + fn replace_edge_body_with_chamfer( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + base_face: FaceKey, + base_distance: f64, + other_distance: f64, + ) -> bool { + let i = self.active_tab; + 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 edge body was retained.").as_ref()); + return false; + }; + let ordered_edges = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered_edges + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); + return false; + }; + let Some(history_face) = source + .face_keys() + .position(|candidate| candidate == base_face) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + else { + self.command_line + .push_error(crate::t!("The selected base face no longer belongs to the body.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "CHAMFEREDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) { self.command_line - .push_error(crate::t!("The solid could not be updated.").as_ref()); + .push_error(crate::t!("The edge body history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::chamfer_op( + history_edges, + history_face, + base_distance, + other_distance, + ), + ) { + self.command_line + .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); + return false; + } + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return false; + }; + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); return false; } self.tabs[i] @@ -682,19 +738,20 @@ impl super::OpenCADStudio { &mut self, handle: Handle, edges: &[EdgeKey], + base_face: Option, value: f64, + other_value: f64, fillet: bool, ) -> Task { let i = self.active_tab; if self.reject_locked_edit(i, handle) { return Task::none(); } - if !matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(EntityType::Solid3D(_)) - ) { + if !matches!(self.tabs[i].scene.document.get_entity(handle), Some( + EntityType::Solid3D(_) | EntityType::Surface(_) + )) { self.command_line - .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + .push_error(crate::t!("Select a 3D solid or surface edge.").as_ref()); return Task::none(); } self.tabs[i].scene.restore_solid_models(&[handle]); @@ -713,9 +770,25 @@ impl super::OpenCADStudio { } } } else { - edges - .first() - .and_then(|edge| cadkernel::brep::chamfer(&body, *edge, value)) + let Some(base_face) = base_face else { + self.command_line + .push_error(crate::t!("CHAMFEREDGE: select a base face edge.").as_ref()); + return Task::none(); + }; + match cadkernel::brep::chamfer_edges( + &body, + edges, + base_face, + value, + other_value, + ) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("CHAMFEREDGE: {error}")); + return Task::none(); + } + } }; let Some(result) = result else { self.command_line.push_error( @@ -724,13 +797,25 @@ impl super::OpenCADStudio { ); return Task::none(); }; - let label = if fillet { "FILLETEDGE" } else { "SOLIDCHAMFER" }; + let label = if fillet { "FILLETEDGE" } else { "CHAMFEREDGE" }; let updated = if fillet { self.replace_solid_body_with_fillet(handle, &body, result, edges, value) } else { - self.replace_solid_body(handle, result, label) + self.replace_edge_body_with_chamfer( + handle, + &body, + result, + edges, + base_face.expect("checked chamfer face"), + value, + other_value, + ) }; if updated { + if !fillet { + self.tabs[i].scene.document.header.chamfer_distance_a = value; + self.tabs[i].scene.document.header.chamfer_distance_b = other_value; + } self.command_line .push_output(crate::tf!("{label}: solid updated.").as_ref()); } diff --git a/src/command.rs b/src/command.rs index f91b124b8..c68bfb9f2 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1551,7 +1551,9 @@ pub enum CmdResult { SolidEdgeBlend { handle: Handle, edges: Vec, + base_face: Option, value: f64, + other_value: f64, fillet: bool, }, SolidSubtract { diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index a88e2fc88..ae7cb1303 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use acadrust::Handle; -use cadkernel::brep::{Body, EdgeKey}; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use glam::DVec3; use crate::command::{ @@ -39,7 +39,8 @@ enum EdgeStep { PreviewConfirm, Radius, Expression, - ChamferValue, + ChamferDistance1, + ChamferDistance2, } pub struct SolidEdgeCommand { @@ -48,14 +49,17 @@ pub struct SolidEdgeCommand { bodies: Vec<(Handle, Body)>, handle: Option, selected_edges: Vec, + base_face: Option, selection_batches: Vec<(Vec, DVec3)>, selection_mode: EdgeSelectionMode, step: EdgeStep, - radius_return: EdgeStep, - loop_candidates: Vec>, + value_return: EdgeStep, + expression_return: EdgeStep, + loop_candidates: Vec<(FaceKey, Vec)>, loop_index: usize, last_pick: Option, default_value: f64, + other_value: f64, preview_color: [f32; 4], preview_wires: Vec, preview_hidden: Vec, @@ -74,10 +78,12 @@ impl SolidEdgeCommand { bodies, handle: None, selected_edges: Vec::new(), + base_face: None, selection_batches: Vec::new(), selection_mode: EdgeSelectionMode::Edge, step: EdgeStep::Selecting, - radius_return: EdgeStep::Selecting, + value_return: EdgeStep::Selecting, + expression_return: EdgeStep::Radius, loop_candidates: Vec::new(), loop_index: 0, last_pick: None, @@ -86,12 +92,29 @@ impl SolidEdgeCommand { } else { 1.0 }, + other_value: 1.0, preview_color, preview_wires: Vec::new(), preview_hidden: Vec::new(), } } + pub fn new_chamfer( + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + distances: (f64, f64), + ) -> Self { + let mut command = Self::new(EdgeOperation::Chamfer, target, bodies, preview_color); + command.default_value = positive_default(distances.0); + command.other_value = positive_default(distances.1); + command + } + + pub fn chamfer_distances(&self) -> (f64, f64) { + (self.default_value, self.other_value) + } + fn body(&self, handle: Handle) -> Option<&Body> { self.bodies .iter() @@ -126,7 +149,13 @@ impl SolidEdgeCommand { fn current_loop(&self) -> Option<&[EdgeKey]> { self.loop_candidates .get(self.loop_index) - .map(Vec::as_slice) + .map(|(_, edges)| edges.as_slice()) + } + + fn current_loop_face(&self) -> Option { + self.loop_candidates + .get(self.loop_index) + .map(|(face, _)| *face) } fn preview_edges(&self) -> Vec { @@ -144,17 +173,24 @@ impl SolidEdgeCommand { } fn preview_active(&self) -> bool { - self.operation == EdgeOperation::Fillet - && matches!( - self.step, - EdgeStep::LoopConfirm - | EdgeStep::PreviewConfirm - | EdgeStep::Radius - | EdgeStep::Expression - ) + matches!( + self.step, + EdgeStep::Selecting + | EdgeStep::PickingLoop + | EdgeStep::LoopConfirm + | EdgeStep::PreviewConfirm + | EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 + ) } - fn preview_for_value(&self, value: f64) -> Option<(Handle, Vec)> { + fn preview_for_values( + &self, + value: f64, + other_value: f64, + ) -> Option<(Handle, Vec)> { if !self.preview_active() { return None; } @@ -163,11 +199,21 @@ impl SolidEdgeCommand { return None; } let (handle, body) = self.active_body()?; - let result = cadkernel::brep::fillet_edges(body, &edges, value).ok()?; + let result = match self.operation { + EdgeOperation::Fillet => cadkernel::brep::fillet_edges(body, &edges, value).ok()?, + EdgeOperation::Chamfer => cadkernel::brep::chamfer_edges( + body, + &edges, + self.base_face.or_else(|| self.current_loop_face())?, + value, + other_value, + ) + .ok()?, + }; let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); for wire in &mut wires { wire.color = self.preview_color; - wire.name = format!("{}-FILLETEDGE-PREVIEW", handle.value()); + wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); } (!wires.is_empty()).then_some((handle, wires)) } @@ -175,14 +221,16 @@ impl SolidEdgeCommand { fn rebuild_preview(&mut self) { self.preview_wires.clear(); self.preview_hidden.clear(); - if let Some((handle, wires)) = self.preview_for_value(self.default_value) { + if let Some((handle, wires)) = + self.preview_for_values(self.default_value, self.other_value) + { self.preview_wires = wires; self.preview_hidden.push(handle); } } fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult { - self.radius_return = return_to; + self.value_return = return_to; self.step = EdgeStep::Radius; self.rebuild_preview(); CmdResult::NeedPoint @@ -196,7 +244,33 @@ impl SolidEdgeCommand { if self.operation == EdgeOperation::Fillet { set_fillet_radius(value); } - self.step = self.radius_return; + self.step = self.value_return; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn begin_distances(&mut self, return_to: EdgeStep) -> CmdResult { + self.value_return = return_to; + self.step = EdgeStep::ChamferDistance1; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_distance(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + match self.step { + EdgeStep::ChamferDistance1 => { + self.default_value = value; + self.step = EdgeStep::ChamferDistance2; + } + EdgeStep::ChamferDistance2 => { + self.other_value = value; + self.step = self.value_return; + } + _ => return None, + } self.rebuild_preview(); Some(CmdResult::NeedPoint) } @@ -206,6 +280,9 @@ impl SolidEdgeCommand { self.current_loop().map(|edges| edges.to_vec()), self.last_pick, ) { + if self.operation == EdgeOperation::Chamfer && self.base_face.is_none() { + self.base_face = self.current_loop_face(); + } self.add_batch(edges, anchor); } self.loop_candidates.clear(); @@ -225,7 +302,9 @@ impl SolidEdgeCommand { CmdResult::SolidEdgeBlend { handle, edges: self.selected_edges.clone(), + base_face: self.base_face, value: self.default_value, + other_value: self.other_value, fillet: self.operation == EdgeOperation::Fillet, } } @@ -235,20 +314,31 @@ impl CadCommand for SolidEdgeCommand { fn name(&self) -> &'static str { match self.operation { EdgeOperation::Fillet => "FILLETEDGE", - EdgeOperation::Chamfer => "SOLIDCHAMFER", + EdgeOperation::Chamfer => "CHAMFEREDGE", } } fn prompt(&self) -> String { match (self.operation, self.step, self.selection_mode) { (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => { - crate::t!("Select a solid edge:").into_owned() + crate::t!("Select an edge or [Loop/Distance]:").into_owned() } - (EdgeOperation::Chamfer, EdgeStep::ChamferValue, _) => crate::tf!( - "Specify chamfer distance <{:.3}>:", + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the chamfer or [Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1, _) => crate::tf!( + "Specify Distance1 or [Expression] <{:.4}>:", self.default_value ) .into_owned(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2, _) => crate::tf!( + "Specify Distance2 or [Expression] <{:.4}>:", + self.other_value + ) + .into_owned(), (_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => { crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned() } @@ -264,7 +354,7 @@ impl CadCommand for SolidEdgeCommand { (_, EdgeStep::PreviewConfirm, _) => { crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned() } - (_, EdgeStep::Radius, _) if self.radius_return == EdgeStep::PreviewConfirm => crate::tf!( + (_, EdgeStep::Radius, _) if self.value_return == EdgeStep::PreviewConfirm => crate::tf!( "Specify Radius or [Expression] <{:.4}>:", self.default_value ) @@ -281,6 +371,26 @@ impl CadCommand for SolidEdgeCommand { fn options(&self) -> Vec { match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => vec![ + CmdOption::new("Loop", "L"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Distance", "D")] + } + ( + EdgeOperation::Chamfer, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2, + _, + ) => vec![CmdOption::new("Expression", "E")], (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![ CmdOption::new("Chain", "C"), CmdOption::new("Loop", "L"), @@ -338,14 +448,14 @@ impl CadCommand for SolidEdgeCommand { self.handle = Some(handle); self.last_pick = Some(point); - if self.operation == EdgeOperation::Chamfer { - self.add_batch(vec![seed], point); - self.step = EdgeStep::ChamferValue; - return CmdResult::NeedPoint; - } - if self.step == EdgeStep::PickingLoop { self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + if self.operation == EdgeOperation::Chamfer { + if let Some(base_face) = self.base_face { + self.loop_candidates + .retain(|(face, _)| *face == base_face); + } + } self.loop_index = 0; self.step = if self.loop_candidates.is_empty() { EdgeStep::Selecting @@ -356,16 +466,38 @@ impl CadCommand for SolidEdgeCommand { return CmdResult::NeedPoint; } + if self.operation == EdgeOperation::Chamfer { + if self.base_face.is_none() { + self.base_face = self.body(handle).and_then(|body| { + nearest_edge_face(body, seed, point.to_array()) + }); + } + let Some(base_face) = self.base_face else { + return CmdResult::NeedPoint; + }; + if !edge_belongs_to_face(self.body(handle).expect("pick body exists"), seed, base_face) + { + return CmdResult::NeedPoint; + } + self.add_batch(vec![seed], point); + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + let edges = match self.selection_mode { EdgeSelectionMode::Edge => vec![seed], EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed), }; self.add_batch(edges, point); + self.rebuild_preview(); CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { - if !matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) { + if !matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { return CmdResult::NeedPoint; } let Some(anchor) = self.last_pick else { @@ -375,26 +507,65 @@ impl CadCommand for SolidEdgeCommand { if value <= 0.0 || !value.is_finite() { return CmdResult::NeedPoint; } - if self.step == EdgeStep::ChamferValue { - self.default_value = value; - self.finish() - } else { - self.accept_radius(value).unwrap_or(CmdResult::NeedPoint) + match self.step { + EdgeStep::Radius => self.accept_radius(value).unwrap_or(CmdResult::NeedPoint), + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + self.accept_distance(value).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::NeedPoint, } } fn on_text_input(&mut self, text: &str) -> Option { let keyword = text.trim().to_ascii_uppercase(); if self.operation == EdgeOperation::Chamfer { - if self.step != EdgeStep::ChamferValue { - return None; - } - let value = keyword.parse::().ok()?; - if value <= 0.0 || !value.is_finite() { - return None; - } - self.default_value = value; - return Some(self.finish()); + return match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::Selecting)), + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.expression_return = self.step; + self.step = EdgeStep::Expression; + Some(CmdResult::NeedPoint) + } else { + let value = keyword.parse::().ok()?; + self.accept_distance(value) + } + } + EdgeStep::Expression => { + let value = crate::app::expr_eval::eval_number(text)?; + self.step = self.expression_return; + self.accept_distance(value) + } + EdgeStep::Radius => None, + }; } match self.step { @@ -450,16 +621,29 @@ impl CadCommand for SolidEdgeCommand { self.accept_radius(value) } EdgeStep::Expression => { - let value = keyword.parse::().ok()?; + let value = crate::app::expr_eval::eval_number(text)?; self.accept_radius(value) } - EdgeStep::ChamferValue => None, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => None, } } fn on_enter(&mut self) -> CmdResult { match (self.operation, self.step) { - (EdgeOperation::Chamfer, EdgeStep::ChamferValue) => self.finish(), + (EdgeOperation::Chamfer, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm) => self.accept_loop(), + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm) => self.finish(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1) => self + .accept_distance(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2) => self + .accept_distance(self.other_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::Expression) => CmdResult::NeedPoint, (EdgeOperation::Chamfer, _) => CmdResult::Cancel, (_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { self.step = EdgeStep::PreviewConfirm; @@ -477,9 +661,6 @@ impl CadCommand for SolidEdgeCommand { } fn on_undo_step(&mut self) -> Option { - if self.operation != EdgeOperation::Fillet { - return None; - } if self.step == EdgeStep::LoopConfirm { self.loop_candidates.clear(); self.loop_index = 0; @@ -495,6 +676,7 @@ impl CadCommand for SolidEdgeCommand { } if self.selected_edges.is_empty() { self.handle = None; + self.base_face = None; } self.last_pick = self .selection_batches @@ -508,17 +690,27 @@ impl CadCommand for SolidEdgeCommand { fn wants_text_input(&self) -> bool { matches!( self.step, - EdgeStep::Radius | EdgeStep::Expression | EdgeStep::ChamferValue + EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 ) } fn dyn_commit_as_text(&self) -> bool { - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) } fn dyn_spec(&self) -> Option { let pick = self.last_pick?; - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue).then(|| DynSpec { + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) + .then(|| DynSpec { anchor: DynAnchor::Point(pick), fields: vec![DynFieldSpec::new(match self.operation { EdgeOperation::Fillet => DynRole::Radius, @@ -530,16 +722,26 @@ impl CadCommand for SolidEdgeCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) .then(|| self.last_pick.map(|pick| cursor.distance(pick))) .flatten() } fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { - if self.step == EdgeStep::Radius { + if matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { if value > 0.0 && value.is_finite() { - if let Some((handle, wires)) = self.preview_for_value(value) { + let values = match self.step { + EdgeStep::ChamferDistance2 => (self.default_value, value), + _ => (value, self.other_value), + }; + if let Some((handle, wires)) = self.preview_for_values(values.0, values.1) { self.preview_wires = wires; self.preview_hidden.clear(); self.preview_hidden.push(handle); @@ -558,7 +760,7 @@ impl CadCommand for SolidEdgeCommand { } } -fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec<(FaceKey, Vec)> { let Some(edge) = body.edges.get(seed) else { return Vec::new(); }; @@ -575,13 +777,48 @@ fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { .iter() .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) .collect::>(); - if !edges.is_empty() && !candidates.contains(&edges) { - candidates.push(edges); + let candidate = (edge_loop.owner, edges); + if !candidate.1.is_empty() && !candidates.contains(&candidate) { + candidates.push(candidate); } } candidates } +fn edge_belongs_to_face(body: &Body, edge: EdgeKey, face: FaceKey) -> bool { + body.edges.get(edge).is_some_and(|edge| { + edge.coedges.iter().any(|coedge| { + body.coedges + .get(*coedge) + .and_then(|coedge| body.loops.get(coedge.owner)) + .is_some_and(|edge_loop| edge_loop.owner == face) + }) + }) +} + +fn nearest_edge_face(body: &Body, edge: EdgeKey, point: [f64; 3]) -> Option { + let edge = body.edges.get(edge)?; + edge.coedges + .iter() + .filter_map(|coedge| { + let edge_loop = body.loops.get(body.coedges.get(*coedge)?.owner)?; + let face = body.faces.get(edge_loop.owner)?; + let surface = body.surfaces.get(face.surface)?; + let distance = surface.distance_to(point).abs(); + distance.is_finite().then_some((edge_loop.owner, distance)) + }) + .min_by(|first, second| first.1.total_cmp(&second.1)) + .map(|(face, _)| face) +} + +fn positive_default(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + 1.0 + } +} + fn edge_chain(body: &Body, seed: EdgeKey) -> Vec { let mut chain = vec![seed]; let mut next = 0; @@ -643,5 +880,5 @@ fn edge_tangent_from_vertex( } inventory::submit!(crate::command::CommandRegistration { - names: &["FILLETEDGE", "SOLIDFILLET", "SOLIDCHAMFER"] + names: &["FILLETEDGE", "SOLIDFILLET", "CHAMFEREDGE", "SOLIDCHAMFER"] }); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 72699a0b7..d33b28208 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -89,7 +89,7 @@ impl CadModule for ModelModule { title: "Edges", tools: vec![ RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), - RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)), ], }, ] diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 0e86cd324..650b90b66 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,7 +1,8 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ - DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryChamfer, + SolidHistoryCone, SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, + SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; @@ -33,6 +34,8 @@ pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; pub const GRIP_FILLET_RADIUS: usize = 10_014; +pub const GRIP_CHAMFER_DISTANCE1: usize = 10_015; +pub const GRIP_CHAMFER_DISTANCE2: usize = 10_016; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -3132,6 +3135,104 @@ pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option { None } +pub fn chamfer_distance_grips( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryChamfer, +) -> Vec { + let Some(operations) = document.solid_history_operations(handle) else { + return Vec::new(); + }; + let Some(chamfer_index) = operations + .iter() + .rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_))) + else { + return Vec::new(); + }; + let Ok(source) = cadkernel::acis::rebuild_history(&operations[..chamfer_index]) else { + return Vec::new(); + }; + let edges = source.edge_keys().collect::>(); + let faces = source.face_keys().collect::>(); + let Some(edge) = value + .edges + .first() + .and_then(|ordinal| usize::try_from(*ordinal).ok()) + .and_then(|ordinal| edges.get(ordinal).copied()) + .and_then(|edge| source.edges.get(edge)) + else { + return Vec::new(); + }; + let Some(base_face) = usize::try_from(value.base_face) + .ok() + .and_then(|ordinal| faces.get(ordinal).copied()) + else { + return Vec::new(); + }; + let adjacent = edge + .coedges + .iter() + .filter_map(|coedge| source.coedges.get(*coedge)) + .filter_map(|coedge| source.loops.get(coedge.owner)) + .map(|edge_loop| edge_loop.owner) + .collect::>(); + let Some(other_face) = adjacent.iter().copied().find(|face| *face != base_face) else { + return Vec::new(); + }; + let outward = |face_key| { + let face = source.faces.get(face_key)?; + let Surface::Plane(plane) = source.surfaces.get(face.surface)? else { + return None; + }; + let normal = glam::DVec3::from_array(plane.normal()?).try_normalize()?; + Some(if face.forward { normal } else { -normal }) + }; + let Some(base_normal) = outward(base_face) else { + return Vec::new(); + }; + let Some(other_normal) = outward(other_face) else { + return Vec::new(); + }; + let dot = base_normal.dot(other_normal); + let Some(base_inward) = (-(other_normal - base_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(other_inward) = (-(base_normal - other_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(start) = source.vertices.get(edge.start) else { + return Vec::new(); + }; + let Some(end) = source.vertices.get(edge.end) else { + return Vec::new(); + }; + let midpoint = + (glam::DVec3::from_array(start.point) + glam::DVec3::from_array(end.point)) * 0.5; + [ + ( + GRIP_CHAMFER_DISTANCE1, + value.base_distance, + base_inward, + ), + ( + GRIP_CHAMFER_DISTANCE2, + value.other_distance, + other_inward, + ), + ] + .into_iter() + .filter(|(_, distance, _)| distance.is_finite() && *distance > 0.0) + .map(|(id, distance, axis)| { + grip( + id, + midpoint + axis * distance, + GripShape::Square, + Some(axis), + ) + }) + .collect() +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3151,6 +3252,9 @@ pub fn primitive_grips( .into_iter() .collect(); } + if let SolidHistoryOperation::Chamfer(value) = operation { + return chamfer_distance_grips(document, handle, value); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3782,3 +3886,21 @@ pub fn fillet_op(edges: Vec, radius: f64) -> SolidHistoryOperation { ..SolidHistoryFillet::default() }) } + +pub fn chamfer_op( + edges: Vec, + base_face: i32, + base_distance: f64, + other_distance: f64, +) -> SolidHistoryOperation { + SolidHistoryOperation::Chamfer(SolidHistoryChamfer { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + base_distance, + other_distance, + edges, + base_face, + ..SolidHistoryChamfer::default() + }) +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 47dc462ca..da829a765 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1183,6 +1183,47 @@ impl Scene { value.radii.push(radius); return self.preview_solid_history(handle, operation); } + if matches!( + grip_id, + crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + | crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE2 + ) { + let definitions = { + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &operation else { + return false; + }; + crate::scene::model::solid_history::chamfer_distance_grips( + &self.document, + handle, + value, + ) + }; + let Some(definition) = definitions.into_iter().find(|grip| grip.id == grip_id) else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { + return false; + }; + let distance = if grip_id + == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + { + &mut value.base_distance + } else { + &mut value.other_distance + }; + *distance += change; + if !distance.is_finite() || *distance <= 1.0e-6 { + return false; + } + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, From 43f97a3bcb5a2d72f3ae219e2a83cd2f2e6bde4d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:06:52 +0300 Subject: [PATCH 6/7] Use analytic chamfer kernel support --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c45a8f9d5..2c9277586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4#2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" +source = "git+https://github.com/ramox81/cadkernel.git?rev=55a09bc3f79fde624e7daf7249ed570675e0aae2#55a09bc3f79fde624e7daf7249ed570675e0aae2" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1d8f7725b..cf0cfc59f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "55a09bc3f79fde624e7daf7249ed570675e0aae2" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 261ab66afdc87df7449d9660e2be816aee62b6e2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:11:23 +0300 Subject: [PATCH 7/7] Restore primitive properties after edge operations --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- src/app/model_ops.rs | 10 +++++++++ src/scene/model/solid_history.rs | 19 +++++++++++++--- src/scene/modify.rs | 37 +++++++++++++++++++++++++++----- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c9277586..08c01a023 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/ramox81/cadcodec.git?rev=af1f31287d4730f228cbb27d6884078295627ca7#af1f31287d4730f228cbb27d6884078295627ca7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc#5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=55a09bc3f79fde624e7daf7249ed570675e0aae2#55a09bc3f79fde624e7daf7249ed570675e0aae2" +source = "git+https://github.com/ramox81/cadkernel.git?rev=9d872e04e1b69654e98ce6184ca3efd28a5eb830#9d872e04e1b69654e98ce6184ca3efd28a5eb830" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index cf0cfc59f..24c30c314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,10 +74,10 @@ 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://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "55a09bc3f79fde624e7daf7249ed570675e0aae2" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "9d872e04e1b69654e98ce6184ca3efd28a5eb830" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 17c39b765..7792c9f90 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -625,6 +625,11 @@ impl super::OpenCADStudio { .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); return false; } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; @@ -713,6 +718,11 @@ impl super::OpenCADStudio { .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); return false; } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 650b90b66..58a5a6e3d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -201,7 +201,7 @@ pub fn has_specialized_primitive_properties( handle: acadrust::Handle, ) -> bool { matches!( - document.solid_history_operation(handle), + primitive_property_operation(document, handle).as_ref(), Some( SolidHistoryOperation::Box(_) | SolidHistoryOperation::Wedge(_) @@ -218,6 +218,19 @@ pub fn has_specialized_primitive_properties( ) } +/// Return the primitive that owns the public Geometry rows. Later operations +/// such as edge fillets and chamfers refine that primitive but do not replace +/// its editable type, position, or dimensions. +pub fn primitive_property_operation( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Option { + document + .solid_history_operations(handle) + .and_then(|operations| operations.into_iter().next()) + .or_else(|| document.solid_history_operation(handle).cloned()) +} + /// Solid history results whose public Properties palette is fully described by /// the common entity rows plus [`primitive_properties`]. A generic B-rep has /// no stable primitive dimensions to expose, but it still uses the compact @@ -1442,10 +1455,10 @@ pub fn primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { + let Some(operation) = primitive_property_operation(document, handle) else { return brep_properties(document, handle); }; - match operation { + match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index da829a765..50f839d34 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -828,8 +828,13 @@ impl Scene { pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document - .solid_history_operation(handle) - .and_then(crate::scene::model::solid_history::reference_point); + .solid_history_operations(handle) + .and_then(|operations| { + operations + .iter() + .rev() + .find_map(crate::scene::model::solid_history::reference_point) + }); let Some(reference) = reference else { return; }; @@ -883,7 +888,24 @@ impl Scene { } (EntityType::Solid3D(_), _) => { let mut operations = self.document.solid_history_operations(handle)?; - *operations.last_mut()? = operation.clone(); + let replacement_id = operation.base().map(|base| { + if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + } + })?; + let target = operations.iter_mut().find(|candidate| { + candidate.base().is_some_and(|base| { + let node_id = if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + }; + node_id == replacement_id + }) + })?; + *target = operation.clone(); cadkernel::acis::rebuild_history(&operations).ok() } _ => None, @@ -938,7 +960,7 @@ impl Scene { self.record_solid_history_before(handle); if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1240,7 +1262,12 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) + else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property(