diff --git a/Cargo.lock b/Cargo.lock index 78e48ab4c..0e79115dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=ad9b1a25ec0d3dc217e24d9c525709ec18484603#ad9b1a25ec0d3dc217e24d9c525709ec18484603" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=393d6c0857447f9ca678bed97a612707f0a842c6#393d6c0857447f9ca678bed97a612707f0a842c6" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=76ae7f836f5e6d7aa81cefb79330698e38adf95a#76ae7f836f5e6d7aa81cefb79330698e38adf95a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5#fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index dc8114115..e7dbf0d58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ad9b1a25ec0d3dc217e24d9c525709ec18484603", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76ae7f836f5e6d7aa81cefb79330698e38adf95a", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "ad9b1a25ec0d3dc217e24d9c525709ec18484603" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index f7636aebd..4083ab14b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -750,6 +750,88 @@ impl OpenCADStudio { task } + /// Resolve the cell under `click` on table `handle` (or any table in the drawing + /// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell + /// editor unless the cell's content is locked. Shared by the double-click shortcut + /// and the TABLEDIT point pick so both agree on grid coordinates and editor setup. + pub(crate) fn begin_table_cell_edit( + &mut self, + i: usize, + handle: acadrust::Handle, + click: glam::DVec3, + ) -> crate::modules::annotate::table_cmd::TableCellEditStart { + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + let target = if handle != acadrust::Handle::NULL { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Table(table) => Some((handle, table.clone())), + _ => None, + }) + } else { + // Find all tables whose grid contains `click`. + // Iterating document.entities() visits entities in draw order; + // taking the last match selects the table on top if they overlap. + let mut matched = None; + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, click).is_some() { + matched = Some((h, table.clone())); + } + } + } + matched + }; + let Some((handle, table)) = target else { + return TableCellEditStart::NoCell; + }; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + let Some(hit) = table_cell_at(&table, style, click) else { + return TableCellEditStart::NoCell; + }; + // Arm the Properties cell indicator whether or not the cell turns + // out to be editable (matches the double-click behavior). + let index = hit.index(&table); + self.tabs[i].properties.prop_vertex = index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(index); + self.refresh_properties(); + if hit.locked { + return TableCellEditStart::LockedCell; + } + let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, &table, hit.row, hit.column, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + TableCellEditStart::Started + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; match result { @@ -2392,6 +2474,59 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); let _ = self.dispatch_command(&cmd); } + CmdResult::EditTableCell { handle, point } => { + // TABLEDIT's pick: end the pick phase and hand (table, point) + // to the shared cell-edit launcher. A miss or a locked cell + // re-prompts, matching AutoCAD's select-until-valid loop. + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handle == acadrust::Handle::NULL && self.selection_cycling { + let mut candidates = Vec::new(); + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, point).is_some() { + candidates.push(h); + } + } + } + if candidates.len() >= 2 { + self.cycle_candidates = Some((self.quick_properties_anchor, candidates)); + return Task::none(); + } + } + + match self.begin_table_cell_edit(i, handle, point) { + TableCellEditStart::Started => {} + TableCellEditStart::LockedCell => { + self.command_line.push_error( + crate::t!("The cell is content locked.").as_ref(), + ); + let _ = self.dispatch_command("TABLEDIT"); + } + TableCellEditStart::NoCell => { + self.command_line.push_error( + crate::t!("No editable table cell picked.").as_ref(), + ); + let _ = self.dispatch_command("TABLEDIT"); + } + } + } CmdResult::MatchEntityLayer { dest, src } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -3454,42 +3589,178 @@ impl OpenCADStudio { } } // ── EXTRUDE ──────────────────────────────────────────────────── - CmdResult::ExtrudeEntity { - handle, - height, + CmdResult::ExtrudeEntities { + handles, + extent, + mode, + taper_angle, color: _, } => { - if self.reject_locked_edit(i, handle) { + let delete_sources = self.tabs[i].scene.document.header.delete_objects; + if handles.is_empty() + || handles + .iter() + .any(|handle| self.reject_locked_edit(i, *handle)) + { self.tabs[i].active_cmd = None; return Task::none(); } - use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::command::{ExtrudeExtent, ExtrudeMode}; + use crate::modules::insert::solid3d_cmds::{ + empty_extruded_surface, empty_solid3d, + }; use crate::scene::model::sweep_model; - - let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(entity) = entity_opt { - if let Some(solid) = sweep_model::extruded(&entity, height) { - let history = crate::scene::model::solid_history::brep_op(&solid); - let pending = self.begin_undo(i, "EXTRUDE", 1, true); - let created = self.add_solid_model(empty_solid3d(), solid, history); - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("EXTRUDE: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } + let path = match extent { + ExtrudeExtent::Path(handle) => self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)), + _ => None, + }; + if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() { + self.command_line + .push_error(crate::t!("EXTRUDE: path entity not found.").as_ref()); + self.tabs[i].active_cmd = None; + return Task::none(); + } + let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true); + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + let mut failed = 0usize; + for handle in &handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + failed += 1; + continue; + }; + let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else { + failed += 1; + continue; + }; + let direction = match extent { + ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| { + glam::DVec3::new( + normal[0] * height, + normal[1] * height, + normal[2] * height, + ) + }), + ExtrudeExtent::Direction(direction) => Some(direction), + ExtrudeExtent::Path(_) => None, + }; + let path_direction = path + .as_ref() + .and_then(|(_, path)| sweep_model::straight_path_direction(path)) + .map(glam::DVec3::from_array); + // The creation mode controls closed profiles. Open curves + // always create sheet surfaces. + let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed; + let body = match (creates_surface, &path, direction) { + (false, Some((_, path)), _) => { + sweep_model::extruded_along_path(&entity, path, taper_angle) } + (false, None, Some(direction)) => { + sweep_model::extruded_direction( + &entity, + direction.to_array(), + taper_angle, + ) + } + (true, None, Some(direction)) => { + sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ) + } + (true, Some(_), _) => path_direction.and_then(|direction| { + sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ) + }), + _ => None, + }; + let Some(body) = body else { + failed += 1; + continue; + }; + let created = if creates_surface { + let direction = direction + .or(path_direction) + .unwrap_or(glam::DVec3::ZERO); + self.add_surface_model( + empty_extruded_surface(direction, taper_angle), + body, + ) } else { - self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref()); + let direction = direction.unwrap_or(glam::DVec3::ZERO); + let history = path + .as_ref() + .and_then(|(_, path)| { + sweep_model::sweep_history( + &entity, + path, + taper_angle, + profile.plane.origin, + ) + }) + .or_else(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); + self.add_solid_model(empty_solid3d(), body, history) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_sources { + consumed.push(*handle); + } + } + } + if delete_sources && !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + } + if !created_handles.is_empty() { + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.", + created = created_handles.len(), + failed = failed + ) + .as_ref(), + ); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); } } else { - self.command_line.push_error(crate::t!("EXTRUDE: entity not found.").as_ref()); + self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref()); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + self.refresh_properties(); } CmdResult::PresspullEntity { @@ -3632,13 +3903,21 @@ impl OpenCADStudio { .get_entity(profile_handle) .cloned(); let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let result = profile_ent - .zip(path_ent) - .and_then(|(profile, path)| sweep_model::swept(&profile, &path)); + let result = profile_ent.zip(path_ent).and_then(|(profile, path)| { + let reference_point = sweep_model::profile_of(&profile)?.plane.origin; + let solid = sweep_model::swept(&profile, &path)?; + let history = sweep_model::sweep_history( + &profile, + &path, + 0.0, + reference_point, + ) + .unwrap_or_else(|| solid_history::brep_op(&solid)); + Some((solid, history)) + }); - if let Some(solid) = result { + if let Some((solid, history)) = result { let pending = self.begin_undo(i, "SWEEP", 1, true); - let history = solid_history::brep_op(&solid); let created = self.add_solid_model(empty_solid3d(), solid, history); if !created.is_null() { self.tabs[i].dirty = true; @@ -3675,9 +3954,20 @@ impl OpenCADStudio { .iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) .collect(); - if let Some(solid) = sweep_model::lofted(&profiles) { + let result = sweep_model::loft_history(&profiles) + .and_then(|history| { + cadkernel::acis::rebuild_body(&history) + .ok() + .map(|solid| (solid, history)) + }) + .or_else(|| { + sweep_model::lofted(&profiles).map(|solid| { + let history = solid_history::brep_op(&solid); + (solid, history) + }) + }); + if let Some((solid, history)) = result { let pending = self.begin_undo(i, "LOFT", 1, true); - let history = solid_history::brep_op(&solid); let created = self.add_solid_model(empty_solid3d(), solid, history); if !created.is_null() { self.tabs[i].dirty = true; diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0561d3e96..b697b71df 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -666,6 +666,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "TABLEDIT" => { + use crate::modules::annotate::table_cmd::TableditCommand; + let cmd = TableditCommand::new(); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + "DIMCONTINUE" => { use crate::modules::annotate::dim_continue::DimContinueCommand; let scene = &self.tabs[i].scene; diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 0407c8e1e..ed44e08af 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -736,21 +736,30 @@ impl OpenCADStudio { // ── EXTRUDE ──────────────────────────────────────────────────── "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; - // If a single entity is already selected, skip the pick step. + // A preselection becomes the complete source set; otherwise + // the interactive command gathers any number of profiles. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - if selected.len() == 1 { - let handle = selected[0].0; + if !selected.is_empty() { let mut cmd = ExtrudeCommand::new_named(cmd, color); - if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { - cmd.set_entity_pick_direction( - curve.plane.normal().map(glam::DVec3::from_array), - ); - cmd.on_entity_pick( - handle, - glam::DVec3::from_array(curve.plane.origin), - ); - } + let first_curve = selected + .iter() + .find_map(|(_, entity)| crate::entities::curve::entity_curve(entity)); + let anchor = first_curve + .as_ref() + .map(|curve| glam::DVec3::from_array(curve.plane.origin)) + .unwrap_or(glam::DVec3::ZERO); + let direction = first_curve + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array); + cmd.set_preselection( + selected + .iter() + .map(|(handle, entity)| (*handle, (*entity).clone())) + .collect(), + anchor, + direction, + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 02dc22220..dc90c07ea 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1285,8 +1285,12 @@ impl CadCommand for DrawOrderCommand { } } - fn wants_text_input(&self) -> bool { - !matches!(self.step, DrawOrderStep::SelectObjects) + fn input_kind(&self) -> crate::command::InputKind { + if matches!(self.step, DrawOrderStep::SelectObjects) { + crate::command::InputKind::Point + } else { + crate::command::InputKind::SingleToken + } } fn is_selection_gathering(&self) -> bool { @@ -2116,7 +2120,7 @@ mod tests { // 1. Shortcut 'F' -> Front let mut cmd = DrawOrderCommand::new(vec![h_hatch]); - assert_eq!(cmd.wants_text_input(), true); + assert_eq!(cmd.input_kind().wants_text(), true); let res = cmd.on_text_input("F"); match res { Some(crate::command::CmdResult::Relaunch(c, handles)) => { @@ -2242,14 +2246,14 @@ mod tests { // Start command with no pre-selection let mut cmd = DrawOrderCommand::new(vec![]); assert!(cmd.is_selection_gathering()); - assert!(!cmd.wants_text_input()); + assert!(!cmd.input_kind().wants_text()); // Gather selection let _ = cmd.on_selection_complete(vec![h_hatch]); let enter_res = cmd.on_enter(); assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); assert!(!cmd.is_selection_gathering()); - assert!(cmd.wants_text_input()); + assert!(cmd.input_kind().wants_text()); // Choose verb Front let res = cmd.on_text_input("F"); diff --git a/src/app/mod.rs b/src/app/mod.rs index 173ac41a8..bdf4d6c62 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,7 +7,7 @@ pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; mod document; -mod expr_eval; +pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; @@ -323,6 +323,21 @@ pub enum StartSection { Supporters, } +/// What the Space / Enter keys currently mean at the command line. One +/// decision point (`OpenCADStudio::text_entry_mode`) for every keyboard +/// route that used to re-derive the answer from editor state. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TextEntryMode { + /// Normal command line: Space submits, Enter finalises. + Command, + /// MText preview pane: every key is literal content, Enter is a line break. + MTextPreview, + /// Free-form text prompt (table cell content, TEXT / MTEXT bodies): + /// Space is literal, typed case is preserved, Enter finishes the edit, + /// Shift+Enter inserts a line break. + FreeText, +} + pub(super) struct OpenCADStudio { start: Instant, tabs: Vec, diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 1de87371e..43f174279 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -40,6 +40,33 @@ impl super::OpenCADStudio { handle } + /// Add an open sheet body as a persistent Surface entity and register its + /// exact B-rep for shaded and wireframe display. + pub(super) fn add_surface_model( + &mut self, + mut entity: EntityType, + surface: Body, + ) -> Handle { + let i = self.active_tab; + let EntityType::Surface(inner) = &mut entity else { + return Handle::NULL; + }; + inner.common.plotstyle_flags = 2; + inner.wires = solid_model::edge_wires(&surface); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&surface) + else { + self.command_line + .push_error(crate::t!("The surface could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + inner.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + let Some(handle) = self.commit_entity_handle(entity) else { + return Handle::NULL; + }; + self.tabs[i].scene.register_solid_model(handle, surface); + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5e8e5689d..a1eb51675 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -8,6 +8,7 @@ use crate::app::helpers::{ CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; +use crate::app::TextEntryMode; use crate::modules::ModuleEvent; use crate::scene::pick::grip::{ find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, @@ -199,8 +200,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .get_or_insert_with(String::new) .push_str(&s); } else { - // Command-line entry is shown uppercase. - self.command_line.input.push_str(&s.to_uppercase()); + // Command-line entry is shown uppercase — except in + // free-form text prompts, where the typed case is the + // content (matches the CommandInput route). + if self.is_free_text_active() { + self.command_line.input.push_str(&s); + } else { + self.command_line.input.push_str(&s.to_uppercase()); + } self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT (see CommandInput) let live = self.command_line.input.clone(); @@ -263,7 +270,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.close_history(); // A leading `>` was only a "literal spaces" typing hint (see // CommandSpace) — drop it before the input is interpreted. - if self.command_line.input.starts_with('>') { + // Free-form text prompts keep it: there it is content, not a + // hint, so a cell value like `>Note` commits verbatim. + if !self.is_free_text_active() && self.command_line.input.starts_with('>') { self.command_line.input.remove(0); } // Grip-menu value prompt — consume the typed number and @@ -516,7 +525,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let wants_spaces = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input() && c.wants_text_with_spaces()) + .map(|c| c.is_free_text_step()) .unwrap_or(false); let raw = self.command_line.input.clone(); let toks: Vec = raw.split_whitespace().map(String::from).collect(); @@ -542,7 +551,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } if self.tabs[i].active_cmd.is_some() { - let text = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + // Free-form text is the content itself: no expression + // evaluation, or a cell value like `5*2` would commit as + // `10`. Single-token prompts keep the calculator behavior. + let free_text = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.is_free_text_step()) + .unwrap_or(false); + let raw = self.command_line.input.trim().to_string(); + let text = if free_text { + raw + } else { + crate::app::expr_eval::eval_to_string(&raw) + }; self.command_line.input.clear(); // Offer the typed text to the command's option handler @@ -559,16 +581,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false) { - self.push_ucs_to_cmd(i); - if let Some(result) = self.tabs[i] - .active_cmd - .as_mut() - .and_then(|c| c.on_text_input(&text)) - { - return self.apply_cmd_result(result); + // An empty submit must not be handed to on_text_input — + // a text step would commit the empty string (wiping a + // table cell, clearing an attribute). It falls through + // to the Enter handling below, which ends the step the + // same way a bare Enter does: unchanged. + if !text.is_empty() { + self.push_ucs_to_cmd(i); + if let Some(result) = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|c| c.on_text_input(&text)) + { + return self.apply_cmd_result(result); + } } } @@ -679,9 +708,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } + /// The active command's current step collects free-form prose from the + /// command line. Single decision point for the routes that type into + /// the buffer (CommandInput, Shift+Enter, the view's on_submit wiring). + pub(crate) fn is_free_text_active(&self) -> bool { + self.tabs[self.active_tab] + .active_cmd + .as_ref() + .is_some_and(|c| c.is_free_text_step()) + } + + /// What Space / Enter currently mean. Previously every key handler + /// re-derived this from the MText editor and the active command, and + /// the MText preview case was known only to `CommandSpace` — so Enter + /// behaved differently depending on which route carried the key. + pub(crate) fn text_entry_mode(&self) -> crate::app::TextEntryMode { + if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + return TextEntryMode::MTextPreview; + } + if self.is_free_text_active() { + return TextEntryMode::FreeText; + } + TextEntryMode::Command + } + pub(super) fn on_command_finalize(&mut self) -> Task { - // In the MText preview, Enter inserts a line break. - if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + // In the MText preview, Enter inserts a line break. The + // free-text prompt case falls through deliberately: for it + // Enter *finishes* the edit (Shift+Enter breaks the line, + // handled by the SHIFT+ENTER shortcut route). + if self.text_entry_mode() == TextEntryMode::MTextPreview { self.mtext_type("\n"); return Task::none(); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 27f97734a..1e232cf7e 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -126,7 +126,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); // A point step that also accepts keyword letters (PLINE A/L/C…) keeps // its polar boxes: only letters reach the command line, digits stay @@ -604,7 +604,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); if !wants_text { if let Some(text) = self.tabs[i] @@ -645,7 +645,7 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| { - (c.wants_text_input() && !c.point_step_accepts_keywords()) + (c.input_kind().wants_text() && !c.point_step_accepts_keywords()) || c.dyn_commit_as_text() }) .unwrap_or(false); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9ca1cdb1c..c41f99477 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1,4 +1,4 @@ -use super::{ArrowKey, Message, OpenCADStudio}; +use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode}; use crate::scene::VIEWCUBE_DRAW_PX; use crate::ui::PropertiesPanel; use iced::time::Instant; @@ -1478,14 +1478,23 @@ impl OpenCADStudio { // string, a path, `UCS Z 90` as one line) can be typed; the `>` // is stripped on submit. (Unfocused Space repeats the last // command via CommandSpace.) - // Command-line entry is shown uppercase. - let s = s.to_uppercase(); + // Command-line entry is shown uppercase — except while the + // active command collects free-form text (cell text, TEXT / + // MTEXT bodies), where the typed case is the content and + // Space must stay in the buffer. + let text_with_spaces = self.is_free_text_active(); + let s = if text_with_spaces { s } else { s.to_uppercase() }; // A space submits (acts like Enter). The whole value is handed to // the submit path, which tokenises multi-token lines — so a typed // token, a pasted `LINE 0,0 10,10`, or API-fed text all run their - // spaces as step separators. A leading `>` keeps spaces literal. + // spaces as step separators. A leading `>` keeps spaces literal, + // as does an active free-form text prompt. let sweep = self.sync_active_field_if_any(); - if !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') { + if !text_with_spaces + && !self.command_line.literal_spaces + && !s.starts_with('>') + && s.contains(' ') + { self.command_line.input = s; return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); } @@ -1817,21 +1826,35 @@ impl OpenCADStudio { Message::CommandSubmit => self.on_command_submit(), Message::CommandSpace => { - // Space is a literal space inside the MText preview; otherwise - // it finalises the active command like Enter. - if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { - self.mtext_type(" "); - return Task::none(); + // What Space means is decided in one place: the MText preview + // and free-form text prompts take it as a literal character, + // everything else finalises the active command like Enter. + match self.text_entry_mode() { + TextEntryMode::MTextPreview => { + self.mtext_type(" "); + Task::none() + } + // A free-form text prompt (table cell content, TEXT / + // MTEXT bodies) keeps Space in the buffer as a literal + // character. + TextEntryMode::FreeText => { + self.command_line.input.push(' '); + Task::none() + } + TextEntryMode::Command => { + // A leading `>` (or the persistent `>` toggle) puts + // the command line in "literal space" mode so the user + // can type arguments that contain spaces. The typed + // `>` is stripped on submit. + if self.command_line.literal_spaces + || self.command_line.input.starts_with('>') + { + self.command_line.input.push(' '); + return Task::none(); + } + self.update(Message::CommandFinalize) + } } - // A leading `>` (or the persistent `>` toggle) puts the command - // line in "literal space" mode so the user can type arguments - // that contain spaces; otherwise Space works like Enter. The - // typed `>` is stripped on submit. - if self.command_line.literal_spaces || self.command_line.input.starts_with('>') { - self.command_line.input.push(' '); - return Task::none(); - } - return self.update(Message::CommandFinalize); } Message::CommandFinalize => self.on_command_finalize(), @@ -8201,3 +8224,131 @@ mod prop_pointer_tests { assert!(task.units() > 0); } } + +#[cfg(test)] +mod free_text_entry_tests { + //! The free-form text prompts (table cell content, TEXT / MTEXT bodies) + //! change what Space / Enter / typing mean at the command line. These + //! tests pin the routing through `text_entry_mode` so the three key + //! routes can't drift apart again. + use super::Message; + use crate::app::{OpenCADStudio, TextEntryMode}; + use crate::modules::annotate::table_cmd::TableCellEditCommand; + use acadrust::entities::Table; + use acadrust::types::Vector3; + use acadrust::{EntityType, Handle}; + + /// A test drawing with a 2×2 table and the cell editor active on [0,0], + /// seeded with `cell_text`. + fn app_with_cell_command(cell_text: &str) -> (OpenCADStudio, Handle) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let mut table = Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2); + table.cell_mut(0, 0).unwrap().set_text(cell_text); + let handle = app.tabs[0] + .scene + .document + .add_entity(EntityType::Table(table.clone())) + .unwrap(); + app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0))); + (app, handle) + } + + fn cell_text(app: &OpenCADStudio, _handle: Handle) -> String { + // ReplaceMany commits by erasing the old handle and re-adding the + // entity under a new one, so look the (single) table up by type. + app.tabs[0] + .scene + .document + .entities() + .find_map(|e| match e { + EntityType::Table(table) => Some(table.cell_text(0, 0).unwrap_or("").to_string()), + _ => None, + }) + .expect("table entity missing") + } + + #[test] + fn cell_editor_switches_to_free_text_mode() { + let (mut app, _) = app_with_cell_command(""); + assert_eq!(app.text_entry_mode(), TextEntryMode::FreeText); + assert!(app.is_free_text_active()); + app.tabs[0].active_cmd = None; + assert_eq!(app.text_entry_mode(), TextEntryMode::Command); + assert!(!app.is_free_text_active()); + } + + #[test] + fn cell_edit_typed_space_stays_in_buffer_with_case() { + let (mut app, handle) = app_with_cell_command(""); + // A typed buffer containing a Space must not auto-submit. + let _ = app.update(Message::CommandInput("Foo Bar".into())); + assert_eq!(app.command_line.input, "Foo Bar"); + assert!(app.tabs[0].active_cmd.is_some(), "Space must not submit"); + // The Space key binding inserts a literal space too. + let _ = app.update(Message::CommandSpace); + assert_eq!(app.command_line.input, "Foo Bar "); + // Enter finishes the edit and commits the content, case intact. + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo Bar"); + } + + #[test] + fn cell_edit_slash_n_inserts_line_break() { + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput("Foo/nBar".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo\\PBar"); + } + + #[test] + fn cell_edit_unfocused_typing_preserves_case() { + // The CommandAppendChar route (typing while the command line is not + // focused) must not uppercase free-form text either. + let (mut app, handle) = app_with_cell_command(""); + for ch in ["F", "o", "o"] { + let _ = app.update(Message::CommandAppendChar(ch.into())); + } + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo"); + } + + #[test] + fn cell_edit_skips_expression_evaluation() { + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput("5*2".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "5*2"); + } + + #[test] + fn cell_edit_leading_greater_than_is_content() { + // `>` is only a literal-space hint for command prompts; in free + // text it is content and must not be stripped on submit. + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput(">Note".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), ">Note"); + } + + #[test] + fn cell_edit_empty_enter_ends_edit_without_wiping() { + let (mut app, handle) = app_with_cell_command("Keep me"); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Keep me"); + assert!(app.tabs[0].active_cmd.is_none(), "Enter ends the edit"); + } + + #[test] + fn normal_commands_still_uppercase_and_submit_on_space() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + // No free-text command active: entry is uppercased… + let _ = app.update(Message::CommandInput("lin".into())); + assert_eq!(app.command_line.input, "LIN"); + // …and a pasted multi-token line runs as a command, not as text. + let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into())); + assert!(app.command_line.input.is_empty(), "Space submitted the line"); + assert_eq!(app.text_entry_mode(), TextEntryMode::Command); + } +} diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 55164a91b..84206993f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2690,6 +2690,19 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + // An engaged grip owns the next left press (click-move-click placement + // or the release of a press-drag). Do not let the same press arm the + // normal box/lasso state or trigger another viewport control; the + // release path below will commit the grip. + if self.tabs[i].active_grip.is_some() { + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + return Task::none(); + } + // Interactive navigation tools reuse the middle-button movement path, // so no selection/pick logic runs while the left button drives them. if self.tabs[i].orbit_mode @@ -2879,6 +2892,11 @@ impl OpenCADStudio { )); self.grip_hover = None; self.grip_popup = None; + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); // A grip edit is not a CAD command, so explicitly seed the shared // dynamic-input fields for the newly engaged grip. @@ -2956,10 +2974,7 @@ impl OpenCADStudio { // click doesn't read as an in-progress drag on later moves. { let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.left_down = false; - sel.left_press_pos = None; - sel.left_press_time = None; - sel.left_dragging = false; + sel.clear_left_selection_gesture(); } let moved = grip.last_world != grip.origin_world; if is_click && !moved { @@ -3602,7 +3617,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); if wants_text { if let Some(text) = self.tabs[i] @@ -4033,16 +4048,16 @@ impl OpenCADStudio { ) }) .or_else(|| { - // 3D solids: click anywhere on the shaded - // body — top-level solids and block-internal - // ones together, front-most wins (a block in - // front of a solid resolves to the block). - self.tabs[i].scene.solid_click_hit( + // Normal 3D selection follows the displayed B-rep + // edges. Face-interior picking remains reserved for + // modelling commands that explicitly request it. + self.tabs[i].scene.solid_edge_click_hit( p, view_rot, eye, bounds, candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) }); // Selection filter: drop a pick whose type is excluded. @@ -4352,12 +4367,13 @@ impl OpenCADStudio { ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { - self.tabs[i].scene.solid_click_hit( + self.tabs[i].scene.solid_edge_click_hit( p, view_rot, eye, bounds, candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) }); if let Some(handle) = hit { @@ -4369,102 +4385,14 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } - let table_editor = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|entity| match entity { - AcadEntityType::Table(table) => { - let horizontal = glam::DVec3::new( - table.horizontal_direction.x, - table.horizontal_direction.y, - table.horizontal_direction.z, - ) - .normalize_or(glam::DVec3::X); - let normal = glam::DVec3::new( - table.normal.x, - table.normal.y, - table.normal.z, - ) - .normalize_or(glam::DVec3::Z); - let mut down = horizontal - .cross(normal) - .normalize_or(glam::DVec3::NEG_Y); - let table_style = table.table_style_handle.and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) - } - _ => None, - }) - }); - let flows_up = crate::entities::table::resolved_flow_up( - table, - table_style, - ); - if flows_up { - down = -down; - } - let origin = glam::DVec3::new( - table.insertion_point.x, - table.insertion_point.y, - table.insertion_point.z, - ); - let relative = click_world - origin; - let x = relative.dot(horizontal); - let y = relative.dot(down); - if x < 0.0 || y < 0.0 { - return None; - } - let column = table - .columns - .iter() - .scan(0.0, |offset, column| { - *offset += column.width; - Some(*offset) - }) - .position(|end| x <= end)?; - let row = table - .rows - .iter() - .scan(0.0, |offset, row| { - *offset += row.height; - Some(*offset) - }) - .position(|end| y <= end)?; - let locked = table.cell(row, column).is_some_and(|cell| { - use acadrust::entities::table::CellStateFlags; - cell.state.intersects( - CellStateFlags::CONTENT_LOCKED - | CellStateFlags::CONTENT_READ_ONLY, - ) - }); - Some(( - (!locked).then(|| { - crate::modules::annotate::table_cmd::TableCellEditCommand::new( - handle, table, row, column, - ) - }), - row * table.column_count() + column, - )) - } - _ => None, - }); - if let Some((command, cell_index)) = table_editor { - self.tabs[i].properties.prop_vertex = cell_index; - self.tabs[i].properties.prop_vertex_indicator_active = true; - crate::entities::table::set_prop_current_cell(cell_index); - self.refresh_properties(); - if let Some(command) = command { - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - } + // Table double-click: shared TABLEDIT editor setup. A + // grid miss returns NoCell and falls through to the + // other text/entity editors below; an editable cell or + // a locked cell (indicator armed, no editor) ends the + // double-click handling here. + if self.begin_table_cell_edit(i, handle, click_world) + != crate::modules::annotate::table_cmd::TableCellEditStart::NoCell + { return Task::none(); } // Any text-bearing entity opens its in-place editor @@ -5053,9 +4981,14 @@ impl OpenCADStudio { } if hovered.is_none() { let started = Instant::now(); - hovered = self.tabs[i] - .scene - .solid_hover_hit(p, view_rot, eye, bounds, candidate_handles.as_ref()); + hovered = self.tabs[i].scene.solid_edge_hover_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ); solid_ms = started.elapsed().as_secs_f64() * 1000.0; } self.tabs[i].scene.set_hover_highlight(hovered); diff --git a/src/command.rs b/src/command.rs index 64bb12501..c9899e9d0 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1129,6 +1129,19 @@ impl CadCommand for TCountCommand { } // ── Result token ────────────────────────────────────────────────────────── +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExtrudeMode { + Solid, + Surface, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ExtrudeExtent { + Height(f64), + Direction(DVec3), + Path(Handle), +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1240,6 +1253,12 @@ pub enum CmdResult { }, /// Cancel: discard any preview and end the command. Cancel, + /// End the command and begin in-place editing of the table cell under + /// `point` on table `handle` (TABLEDIT). The host resolves the cell — + /// it owns the document needed for the table-style lookup — honors + /// content locks, and launches the cell editor, re-prompting the + /// command when the pick misses a cell. + EditTableCell { handle: Handle, point: DVec3 }, /// Cancel because the active drawing space changed. Cleanup is identical /// to `Cancel`, but the host reports the context change explicitly. CancelForSpaceChange, @@ -1408,10 +1427,12 @@ pub enum CmdResult { /// Translation vector applied once to every selected point. delta: DVec3, }, - /// Extrude the profile entity `handle` along its plane normal. - ExtrudeEntity { - handle: Handle, - height: f64, + /// Extrude one or more profiles with the requested construction mode. + ExtrudeEntities { + handles: Vec, + extent: ExtrudeExtent, + mode: ExtrudeMode, + taper_angle: f64, color: [f32; 4], }, /// Pull a closed profile or a planar solid face by a signed distance. @@ -1678,6 +1699,38 @@ impl CmdOption { } } +/// What kind of input the command's current step expects from the command line. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum InputKind { + /// Normal point-picking / viewport interaction or keyword options. + /// The command line does not await dedicated text input. + #[default] + Point, + /// Single-token command-line text input (option letters, numeric radius, + /// layer name, block name, numeric angle). Space acts as a submit delimiter, + /// characters are automatically uppercased, and expressions like `5*2` + /// are evaluated. + SingleToken, + /// Free-form text prose (table cell contents, TEXT / MTEXT bodies, + /// dimension text overrides, attribute prompt defaults). Space is a + /// literal character, typed letter casing is preserved, math expression + /// evaluation is bypassed, Enter finishes the edit, and Shift+Enter + /// inserts a line break. + FreeText, +} + +impl InputKind { + /// Returns `true` when the command is waiting for typed text input (`SingleToken` or `FreeText`). + pub fn wants_text(self) -> bool { + matches!(self, InputKind::SingleToken | InputKind::FreeText) + } + + /// Returns `true` when the current prompt collects free-form prose. + pub fn is_free_text(self) -> bool { + self == InputKind::FreeText + } +} + pub trait CadCommand: Send { /// Short name shown in the command line prompt, e.g. `"LINE"`. #[allow(dead_code)] @@ -1904,7 +1957,21 @@ pub trait CadCommand: Send { &[] } + /// What kind of input the current step expects from the command line. + /// Default is [`InputKind::Point`]. + fn input_kind(&self) -> InputKind { + #[allow(deprecated)] + if self.wants_text_with_spaces() { + InputKind::FreeText + } else if self.wants_text_input() { + InputKind::SingleToken + } else { + InputKind::Point + } + } + /// Returns `true` when the command is waiting for text typed in the command line. + #[deprecated(note = "Use `input_kind().wants_text()` instead")] fn wants_text_input(&self) -> bool { false } @@ -1931,17 +1998,19 @@ pub trait CadCommand: Send { /// Returns `true` when the active text prompt expects free-form prose /// that can legitimately contain whitespace (the body of a TEXT / - /// MTEXT / DDEDIT entity, an attribute default value, etc.). For - /// these prompts the command-line input must let `Space` be typed as - /// a literal character; for every other prompt `Space` submits the - /// input the same way `Enter` does. - /// - /// Default `false` — single-token prompts (option letters, numeric - /// radius, block name) do not embed spaces. + /// MTEXT / DDEDIT entity, a table cell, an attribute default value). + #[deprecated(note = "Use `input_kind().is_free_text()` instead")] fn wants_text_with_spaces(&self) -> bool { false } + /// The current step collects free-form prose from the command line: + /// Space is a literal character, typed case is preserved, Enter + /// finishes the edit and Shift+Enter inserts a line break. + fn is_free_text_step(&self) -> bool { + self.input_kind().is_free_text() + } + /// Called when the user submits text via the command line while `wants_text_input` is true. fn on_text_input(&mut self, _text: &str) -> Option { None diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 9ecfa7c70..e4dd5d475 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -5,7 +5,9 @@ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::DVec3; -use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; +use crate::command::{ + CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -159,8 +161,12 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { @@ -169,11 +175,6 @@ impl CadCommand for AlignedDimensionCommand { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. - self.awaiting_text - } - fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index f23e3b555..811dc0602 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -8,7 +8,7 @@ use cadkernel::geom2d::{ use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, WorkingPlane, + DimensionAssociationSource, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -486,12 +486,12 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn options(&self) -> Vec { diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index bbdfaf412..6bfb98a8c 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -6,7 +6,7 @@ use glam::DVec3; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, WorkingPlane, + DimensionAssociationSource, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::dimension_assoc::{ @@ -350,18 +350,18 @@ impl CadCommand for ArcLengthDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { let Step::DimLine(selection) = self.step else { return Vec::new(); diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 19dfcb1ca..5077dae15 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -136,18 +136,18 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { vec![ diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 9a517c968..8c3f219cf 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -5,7 +5,7 @@ use glam::{DVec3, Vec3}; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, - WorkingPlane, + InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::creation_style::DimensionCreationDefaults; @@ -227,18 +227,18 @@ impl CadCommand for JoggedRadiusDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine { .. }) && !self.awaiting_text diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 53d09b08e..68bb4d133 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -5,7 +5,9 @@ use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve, Line as KernelLine, }; -use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; +use crate::command::{ + CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; @@ -219,8 +221,12 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { @@ -229,11 +235,6 @@ impl CadCommand for LinearDimensionCommand { !self.awaiting_text && !self.awaiting_angle && !self.awaiting_rotation } - fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. - self.awaiting_text - } - fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 1214d45d7..c10f4e04a 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -5,7 +5,7 @@ use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Mat4, Vec3}; -use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -469,12 +469,12 @@ impl CadCommand for MLeaderCommand { Some(preview) } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::Layer | Step::PreEnterText) + fn input_kind(&self) -> InputKind { + if matches!(self.step, Step::Layer | Step::PreEnterText) { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index 6af690398..24aaaf77e 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -3,7 +3,7 @@ use acadrust::types::Vector3; use acadrust::MText; use glam::DVec3; -use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -243,12 +243,12 @@ impl CadCommand for MTextCommand { matches!(self.step, Step::FirstCorner | Step::OppositeCorner) } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::Style) + fn input_kind(&self) -> InputKind { + if matches!(self.step, Step::Style) { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn dyn_field(&self) -> DynField { diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 1437ba953..ea5b94c8c 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -139,18 +139,18 @@ impl CadCommand for OrdinateDimCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::LeaderEndpoint { .. }) && !self.awaiting_text diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 4cb6acc9d..f68c26f6b 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -3,7 +3,7 @@ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -134,18 +134,18 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { vec![ diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 0e597cc98..2ffb34878 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -10,7 +10,7 @@ use acadrust::EntityType; use glam::DVec3; use std::sync::{Mutex, OnceLock}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -303,15 +303,19 @@ impl CadCommand for TableCommand { } } - fn wants_text_input(&self) -> bool { - matches!( + fn input_kind(&self) -> InputKind { + if matches!( self.step, Step::Columns | Step::DataRows { .. } | Step::ColumnWidth { .. } | Step::RowHeight { .. } | Step::InsertionMode { .. } - ) + ) { + InputKind::SingleToken + } else { + InputKind::Point + } } fn on_text_input(&mut self, text: &str) -> Option { @@ -479,6 +483,147 @@ impl CadCommand for TableCommand { inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); +/// A table cell resolved from a pick: grid coordinates plus whether its +/// content is locked against editing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TableCellHit { + pub row: usize, + pub column: usize, + pub locked: bool, +} + +/// Outcome of launching the table cell editor for a pick (double-click or +/// TABLEDIT). Distinguishes "cell found but locked" — the caller should +/// stop, the indicator is armed — from "nothing to edit" — the caller may +/// fall through to other handlers or re-prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TableCellEditStart { + /// Cell editor launched. + Started, + /// Cell resolved but content-locked/read-only: indicator armed, no + /// editor. + LockedCell, + /// Not a table, or the pick missed the grid. + NoCell, +} + +impl TableCellHit { + /// Flat cell index, matching the Properties palette ordering. + pub fn index(&self, table: &acadrust::entities::Table) -> usize { + self.row * table.column_count() + self.column + } +} + +/// Resolve the table cell under `click_world`. Shared by the double-click +/// shortcut and the TABLEDIT command so both agree on grid coordinates, +/// flow direction and locked-cell detection. `table_style` is the style +/// object the table references, when the caller has the document at hand; +/// the table's embedded base style covers the common fallbacks. +pub(crate) fn table_cell_at( + table: &acadrust::entities::Table, + table_style: Option<&acadrust::objects::TableStyle>, + click_world: DVec3, +) -> Option { + let horizontal = glam::DVec3::new( + table.horizontal_direction.x, + table.horizontal_direction.y, + table.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(table.normal.x, table.normal.y, table.normal.z) + .normalize_or(glam::DVec3::Z); + let mut down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if crate::entities::table::resolved_flow_up(table, table_style) { + down = -down; + } + let origin = glam::DVec3::new( + table.insertion_point.x, + table.insertion_point.y, + table.insertion_point.z, + ); + let relative = click_world - origin; + let x = relative.dot(horizontal); + let y = relative.dot(down); + if x < 0.0 || y < 0.0 { + return None; + } + let column = table + .columns + .iter() + .scan(0.0, |offset, column| { + *offset += column.width; + Some(*offset) + }) + .position(|end| x <= end)?; + let row = table + .rows + .iter() + .scan(0.0, |offset, row| { + *offset += row.height; + Some(*offset) + }) + .position(|end| y <= end)?; + Some(TableCellHit { + row, + column, + locked: cell_locked(table, row, column), + }) +} + +/// True when the cell's content may not be edited (locked or read-only). +pub(crate) fn cell_locked(table: &acadrust::entities::Table, row: usize, column: usize) -> bool { + use acadrust::entities::table::CellStateFlags; + table.cell(row, column).is_some_and(|cell| { + cell.state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + }) +} + +/// TABLEDIT — edit a table cell's text by picking it. The pick only +/// collects (table, point); the host resolves the cell and launches +/// `TableCellEditCommand`, so the command-first flow and the double-click +/// shortcut share one resolver and one editor setup. +pub struct TableditCommand; + +impl TableditCommand { + pub fn new() -> Self { + Self + } +} + +impl Default for TableditCommand { + fn default() -> Self { + Self::new() + } +} + +impl CadCommand for TableditCommand { + fn name(&self) -> &'static str { + "TABLEDIT" + } + + fn prompt(&self) -> String { + t!("TABLEDIT Select a table cell:").into_owned() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + CmdResult::EditTableCell { + handle: acadrust::Handle::NULL, + point, + } + } + + // A bare Enter at the pick prompt ends the command (AutoCAD leaves the + // select-until-valid loop on Enter). + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["TABLEDIT"] +}); + pub struct TableCellEditCommand { handle: acadrust::Handle, table: acadrust::entities::Table, @@ -508,7 +653,11 @@ impl CadCommand for TableCellEditCommand { } fn prompt(&self) -> String { - let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + let current = self + .table + .cell_text(self.row, self.column) + .unwrap_or("") + .replace("\\P", "/n"); t!( "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:", row = self.row, @@ -518,8 +667,11 @@ impl CadCommand for TableCellEditCommand { .into_owned() } - fn wants_text_input(&self) -> bool { - true + // Cell content is free-form prose ("Foo Bar"): Space must type a + // literal space instead of submitting, Enter finishes, and + // /n or \n inserts a line break. + fn input_kind(&self) -> InputKind { + InputKind::FreeText } fn on_point(&mut self, _point: DVec3) -> CmdResult { @@ -535,7 +687,11 @@ impl CadCommand for TableCellEditCommand { { return Some(CmdResult::Cancel); } - cell.set_text(text); + let formatted = text + .replace("/n", "\\P") + .replace("\\n", "\\P") + .replace('\n', "\\P"); + cell.set_text(&formatted); self.table.block_record_handle = None; Some(CmdResult::ReplaceMany( vec![(self.handle, vec![EntityType::Table(self.table.clone())])], @@ -543,7 +699,126 @@ impl CadCommand for TableCellEditCommand { )) } + // Bare Enter (empty buffer) ends the edit *without* writing — the + // host routes an empty submit here instead of on_text_input(""), so + // pressing Enter on an untouched cell keeps its current content. fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } } + +#[cfg(test)] +mod tabledit_tests { + //! `table_cell_at` is the one resolver shared by the double-click + //! shortcut and the TABLEDIT pick, so its grid math is pinned here. + //! `Table::new` seeds 2.5-unit columns and 0.25-unit rows with the + //! default orientation (horizontal +X, flow down). + use super::{table_cell_at, TableCellEditCommand, TableCellHit, TableditCommand}; + use crate::command::{CadCommand, CmdResult}; + use acadrust::entities::Table; + use acadrust::entities::table::CellStateFlags; + use acadrust::types::Vector3; + use glam::DVec3; + + fn table_2x2() -> Table { + Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2) + } + + #[test] + fn resolves_row_and_column_from_point() { + let table = table_2x2(); + // Centers of the four cells. + assert_eq!( + table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0)), + Some(TableCellHit { + row: 0, + column: 0, + locked: false + }) + ); + assert_eq!( + table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0)), + Some(TableCellHit { + row: 0, + column: 1, + locked: false + }) + ); + assert_eq!( + table_cell_at(&table, None, DVec3::new(1.25, -0.375, 0.0)), + Some(TableCellHit { + row: 1, + column: 0, + locked: false + }) + ); + } + + #[test] + fn misses_outside_the_grid() { + let table = table_2x2(); + // Before the insertion point, beyond the last column/row, and a + // point in the plane's empty space all miss. + assert_eq!( + table_cell_at(&table, None, DVec3::new(-0.1, -0.1, 0.0)), + None + ); + assert_eq!(table_cell_at(&table, None, DVec3::new(6.0, -0.1, 0.0)), None); + assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, -9.0, 0.0)), None); + assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, 1.0, 0.0)), None); + } + + #[test] + fn reports_locked_cells() { + let mut table = table_2x2(); + if let Some(cell) = table.cell_mut(0, 1) { + cell.state |= CellStateFlags::CONTENT_LOCKED; + } + let hit = table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0)) + .expect("locked cell still resolves"); + assert!(hit.locked); + // Its neighbour stays editable through the same pick path. + assert!(!table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0)) + .expect("neighbour resolves") + .locked); + } + + #[test] + fn tabledit_point_hands_point_to_host() { + let mut cmd = TableditCommand::new(); + assert!(!cmd.needs_entity_pick()); + match cmd.on_point(DVec3::new(1.0, 2.0, 0.0)) { + CmdResult::EditTableCell { + handle, + point, + } => { + assert_eq!(handle, acadrust::Handle::NULL); + assert_eq!(point, DVec3::new(1.0, 2.0, 0.0)); + } + _ => panic!("expected EditTableCell"), + } + // A bare Enter at the pick prompt ends the command. + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn table_cell_edit_formats_newlines() { + let table = table_2x2(); + let handle = acadrust::Handle::new(42); + let mut edit_cmd = TableCellEditCommand::new(handle, &table, 0, 0); + let result = edit_cmd + .on_text_input("Line1/nLine2\\nLine3\nLine4") + .expect("cell edit commits"); + match result { + CmdResult::ReplaceMany(repl, _) => { + assert_eq!(repl.len(), 1); + if let acadrust::EntityType::Table(t) = &repl[0].1[0] { + assert_eq!(t.cell_text(0, 0).unwrap(), "Line1\\PLine2\\PLine3\\PLine4"); + } else { + panic!("expected Table entity"); + } + } + _ => panic!("expected ReplaceMany"), + } + } +} diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 5d6c46db4..bba45cb11 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -12,7 +12,7 @@ use acadrust::EntityType; use glam::DVec3; use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::scene::model::wire_model::WireModel; enum Step { @@ -88,14 +88,12 @@ impl CadCommand for AttdefCommand { } } - fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) - } - - fn wants_text_with_spaces(&self) -> bool { - // Tag must be a single token (so disallow spaces there); the - // Prompt and Default value steps are free-form text. - matches!(self.step, Step::Prompt { .. } | Step::Default { .. }) + fn input_kind(&self) -> InputKind { + match self.step { + Step::Insertion { .. } => InputKind::Point, + Step::Tag => InputKind::SingleToken, + Step::Prompt { .. } | Step::Default { .. } => InputKind::FreeText, + } } fn on_text_input(&mut self, text: &str) -> Option { diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index e54671c98..3f65f0d80 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -249,11 +249,11 @@ impl CadCommand for InsertBlockCommand { } } - fn wants_text_input(&self) -> bool { - matches!( - self.step, - Step::Name | Step::FillAttr { .. } | Step::Point { .. } - ) + fn input_kind(&self) -> InputKind { + match self.step { + Step::FillAttr { .. } => InputKind::FreeText, + Step::Name | Step::Point { .. } => InputKind::SingleToken, + } } fn point_step_accepts_keywords(&self) -> bool { @@ -263,11 +263,6 @@ impl CadCommand for InsertBlockCommand { matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() } - fn wants_text_with_spaces(&self) -> bool { - // Block names don't embed whitespace, but attribute values do. - matches!(self.step, Step::FillAttr { .. }) - } - fn on_text_input(&mut self, text: &str) -> Option { match &self.step { Step::Name => { diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index db2898da0..da478712e 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,9 +1,14 @@ // Profile-based kernel solid commands stored as exact ACIS data. -use acadrust::{entities::Solid3D, EntityType}; +use std::sync::{Mutex, OnceLock}; + +use acadrust::{entities::Solid3D, EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, +}; +use crate::scene::WireModel; use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -11,32 +16,173 @@ use crate::t; pub struct ExtrudeCommand { command_name: &'static str, step: ExtrudeStep, - pub target_handle: acadrust::Handle, + handles: Vec, + preview_profiles: Vec<(Handle, EntityType)>, + injected_profile: Option, anchor: DVec3, - direction: Option, + profile_direction: Option, + direction_start: Option, + mode: ExtrudeMode, + taper_angle: f64, + last_height: Option, + taper_return: ExtrudeStep, color: [f32; 4], } -#[derive(PartialEq)] +#[derive(Clone, Copy)] +struct ExtrudeDefaults { + mode: ExtrudeMode, + height: Option, + taper_angle: f64, +} + +fn extrude_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| { + Mutex::new(ExtrudeDefaults { + mode: ExtrudeMode::Solid, + height: None, + taper_angle: 0.0, + }) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] enum ExtrudeStep { Pick, + Mode, Height, + DirectionStart, + DirectionEnd, + Path, + Taper, + Expression, + TaperExpression, } impl ExtrudeCommand { pub fn new_named(name: &str, color: [f32; 4]) -> Self { + let defaults = *extrude_defaults().lock().unwrap_or_else(|error| error.into_inner()); Self { command_name: match name { "THICKEN" => "THICKEN", _ => "EXTRUDE", }, step: ExtrudeStep::Pick, - target_handle: acadrust::Handle::NULL, + handles: Vec::new(), + preview_profiles: Vec::new(), + injected_profile: None, anchor: DVec3::ZERO, - direction: None, + profile_direction: None, + direction_start: None, + mode: defaults.mode, + taper_angle: defaults.taper_angle, + last_height: defaults.height, + taper_return: ExtrudeStep::Height, color, } } + + pub fn set_preselection( + &mut self, + profiles: Vec<(Handle, EntityType)>, + anchor: DVec3, + direction: Option, + ) { + self.handles = profiles.iter().map(|(handle, _)| *handle).collect(); + self.preview_profiles = profiles; + self.anchor = anchor; + self.profile_direction = direction.and_then(DVec3::try_normalize); + if !self.handles.is_empty() { + self.step = ExtrudeStep::Height; + } + } + + fn finish(&self, extent: ExtrudeExtent) -> CmdResult { + if let Some(height) = match extent { + ExtrudeExtent::Height(height) => Some(height), + ExtrudeExtent::Direction(direction) => Some(direction.length()), + ExtrudeExtent::Path(_) => None, + } { + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .height = Some(height); + } + CmdResult::ExtrudeEntities { + handles: self.handles.clone(), + extent, + mode: self.mode, + taper_angle: self.taper_angle, + color: self.color, + } + } + + fn preview_wires(&self, cursor: DVec3) -> Vec { + if self.step != ExtrudeStep::Height { + return Vec::new(); + } + let Some(axis) = self.profile_direction else { + return Vec::new(); + }; + let height = (cursor - self.anchor).dot(axis); + if !height.is_finite() || height.abs() <= 1e-6 { + return Vec::new(); + } + + self.preview_profiles + .iter() + .flat_map(|(_, entity)| { + let Some((profile, closed)) = + crate::scene::model::sweep_model::extrusion_profile_of(entity) + else { + return Vec::new(); + }; + let Some(normal) = profile.plane.normal() else { + return Vec::new(); + }; + let direction = [normal[0] * height, normal[1] * height, normal[2] * height]; + let creates_surface = self.mode == ExtrudeMode::Surface || !closed; + let body = if creates_surface { + crate::scene::model::sweep_model::extruded_surface( + entity, + direction, + self.taper_angle, + ) + } else { + crate::scene::model::sweep_model::extruded_direction( + entity, + direction, + self.taper_angle, + ) + }; + body.map(|body| preview_body_wires(&body, self.color)) + .unwrap_or_default() + }) + .collect() + } +} + +fn preview_body_wires(body: &cadkernel::brep::Body, color: [f32; 4]) -> Vec { + let mesh = cadkernel::brep::mesh::tessellate( + body, + cadkernel::brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + 1e-9, + ), + ); + mesh.edges + .into_iter() + .filter(|edge| edge.positions.len() >= 2) + .map(|edge| { + WireModel::solid_f64( + "EXTRUDE-PREVIEW".to_owned(), + edge.positions, + color, + false, + ) + }) + .collect() } impl CadCommand for ExtrudeCommand { @@ -45,63 +191,249 @@ impl CadCommand for ExtrudeCommand { } fn prompt(&self) -> String { match self.step { - ExtrudeStep::Pick => t!("EXTRUDE Select closed profile (Circle, LwPolyline…):") - .into_owned(), - ExtrudeStep::Height => t!("EXTRUDE Height:").into_owned(), + ExtrudeStep::Pick => t!("EXTRUDE Select objects to extrude or [Mode] (Enter to finish):").into_owned(), + ExtrudeStep::Mode => format!( + "{} <{}>:", + t!("EXTRUDE Creation mode [Solid/Surface]"), + if self.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" } + ), + ExtrudeStep::Height => { + let base = t!("EXTRUDE Specify height or [Direction/Path/Taper angle/Expression]"); + self.last_height.map_or_else( + || format!("{base}:"), + |height| format!("{base} <{}>:", crate::entities::common::format_length(height)), + ) + } + ExtrudeStep::DirectionStart => t!("EXTRUDE Start point of direction:").into_owned(), + ExtrudeStep::DirectionEnd => t!("EXTRUDE End point of direction:").into_owned(), + ExtrudeStep::Path => t!("EXTRUDE Select extrusion path or [Taper angle]:").into_owned(), + ExtrudeStep::Taper => format!( + "{} <{}>:", + t!("EXTRUDE Specify taper angle or [Expression]"), + crate::entities::common::format_angle(self.taper_angle) + ), + ExtrudeStep::Expression => t!("EXTRUDE Enter height expression:").into_owned(), + ExtrudeStep::TaperExpression => t!("EXTRUDE Enter taper angle expression:").into_owned(), + } + } + fn options(&self) -> Vec { + match self.step { + ExtrudeStep::Pick => vec![CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + ExtrudeStep::Mode => vec![ + CmdOption::new("Solid", "SOLID"), + CmdOption::new("Surface", "SURFACE"), + ], + ExtrudeStep::Height => vec![ + CmdOption::new("Direction", "DIRECTION"), + CmdOption::new("Path", "PATH"), + CmdOption::new("Taper angle", "TAPER"), + CmdOption::new("Expression", "EXPRESSION"), + ], + ExtrudeStep::Path => vec![CmdOption::new("Taper angle", "TAPER")], + ExtrudeStep::Taper => vec![CmdOption::new("Expression", "EXPRESSION")], + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.step == ExtrudeStep::Pick + self.step == ExtrudeStep::Path } fn entity_pick_uses_surface_point(&self) -> bool { true } fn set_entity_pick_direction(&mut self, direction: Option) { - self.direction = direction.and_then(DVec3::try_normalize); + if self.step == ExtrudeStep::Pick { + self.profile_direction = direction.and_then(DVec3::try_normalize); + } } - fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - self.target_handle = handle; - self.anchor = point; - self.step = ExtrudeStep::Height; - CmdResult::NeedPoint + match self.step { + ExtrudeStep::Pick => { + if !self.handles.contains(&handle) { + self.handles.push(handle); + self.anchor = point; + if let Some(entity) = self.injected_profile.take() { + self.preview_profiles.push((handle, entity)); + } + } else { + self.injected_profile = None; + } + CmdResult::NeedPoint + } + ExtrudeStep::Path if !self.handles.contains(&handle) => { + self.finish(ExtrudeExtent::Path(handle)) + } + _ => CmdResult::NeedPoint, + } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.step == ExtrudeStep::Height { - let height = self - .direction - .map(|direction| (pt - self.anchor).dot(direction)) - .unwrap_or_else(|| pt.distance(self.anchor)); - if !height.is_finite() || height.abs() <= 1e-6 { - return CmdResult::NeedPoint; + match self.step { + ExtrudeStep::Height => { + let height = self + .profile_direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if height.is_finite() && height.abs() > 1e-6 { + self.finish(ExtrudeExtent::Height(height)) + } else { + CmdResult::NeedPoint + } } - return CmdResult::ExtrudeEntity { - handle: self.target_handle, - height, - color: self.color, - }; + ExtrudeStep::DirectionStart => { + self.direction_start = Some(pt); + self.step = ExtrudeStep::DirectionEnd; + CmdResult::NeedPoint + } + ExtrudeStep::DirectionEnd => { + let direction = pt - self.direction_start.unwrap_or(pt); + if direction.is_finite() && direction.length_squared() > 1e-12 { + self.finish(ExtrudeExtent::Direction(direction)) + } else { + CmdResult::NeedPoint + } + } + _ => CmdResult::NeedPoint, } - CmdResult::NeedPoint } fn wants_text_input(&self) -> bool { - self.step == ExtrudeStep::Height + matches!( + self.step, + ExtrudeStep::Pick + | ExtrudeStep::Mode + | ExtrudeStep::Height + | ExtrudeStep::Path + | ExtrudeStep::Taper + | ExtrudeStep::Expression + | ExtrudeStep::TaperExpression + ) + } + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, ExtrudeStep::Height | ExtrudeStep::Path) } fn on_text_input(&mut self, text: &str) -> Option { - crate::entities::common::parse_typed_length(text) - .filter(|&h| h.abs() > 1e-6) - .map(|h| CmdResult::ExtrudeEntity { - handle: self.target_handle, - height: h, - color: self.color, - }) + let value = text.trim(); + // A bare Enter finalizes the current source selection through + // `on_enter`. Treating the empty string as an option prefix makes it + // match every keyword and silently advances to the wrong branch. + if value.is_empty() { + return None; + } + match self.step { + ExtrudeStep::Pick if "MODE".starts_with(&value.to_ascii_uppercase()) => { + self.step = ExtrudeStep::Mode; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Mode => { + let upper = value.to_ascii_uppercase(); + if "SOLID".starts_with(&upper) { + self.mode = ExtrudeMode::Solid; + } else if "SURFACE".starts_with(&upper) { + self.mode = ExtrudeMode::Surface; + } else { + return Some(CmdResult::NeedPoint); + } + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .mode = self.mode; + self.step = ExtrudeStep::Pick; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Height => { + let upper = value.to_ascii_uppercase(); + if "DIRECTION".starts_with(&upper) { + self.step = ExtrudeStep::DirectionStart; + return Some(CmdResult::NeedPoint); + } + if "PATH".starts_with(&upper) { + self.step = ExtrudeStep::Path; + return Some(CmdResult::NeedPoint); + } + if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) { + self.taper_return = ExtrudeStep::Height; + self.step = ExtrudeStep::Taper; + return Some(CmdResult::NeedPoint); + } + if "EXPRESSION".starts_with(&upper) { + self.step = ExtrudeStep::Expression; + return Some(CmdResult::NeedPoint); + } + crate::entities::common::parse_typed_length(value) + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))) + } + ExtrudeStep::Path => { + let upper = value.to_ascii_uppercase(); + if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) { + self.taper_return = ExtrudeStep::Path; + self.step = ExtrudeStep::Taper; + } + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Taper => { + if "EXPRESSION".starts_with(&value.to_ascii_uppercase()) { + self.step = ExtrudeStep::TaperExpression; + return Some(CmdResult::NeedPoint); + } + let angle = crate::entities::common::parse_angle(value)?; + if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Some(CmdResult::NeedPoint); + } + self.taper_angle = angle; + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .taper_angle = angle; + self.step = self.taper_return; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Expression => crate::app::expr_eval::eval_number(value) + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))), + ExtrudeStep::TaperExpression => { + let angle = crate::app::expr_eval::eval_number(value) + .and_then(|value| crate::entities::common::parse_angle(&value.to_string()))?; + if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Some(CmdResult::NeedPoint); + } + self.taper_angle = angle; + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .taper_angle = angle; + self.step = self.taper_return; + Some(CmdResult::NeedPoint) + } + _ => None, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + ExtrudeStep::Pick if !self.handles.is_empty() => { + self.step = ExtrudeStep::Height; + CmdResult::NeedPoint + } + ExtrudeStep::Mode => { + self.step = ExtrudeStep::Pick; + CmdResult::NeedPoint + } + ExtrudeStep::Height => self + .last_height + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))) + .unwrap_or(CmdResult::Cancel), + ExtrudeStep::Taper => { + self.step = self.taper_return; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { - (self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?)) + (self.step == ExtrudeStep::Height) + .then_some((self.anchor, self.profile_direction?)) } fn dyn_spec(&self) -> Option { (self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec { @@ -114,7 +446,46 @@ impl CadCommand for ExtrudeCommand { }) } fn dyn_live_value(&self, cursor: DVec3) -> Option { - Some((cursor - self.anchor).dot(self.direction?)) + Some((cursor - self.anchor).dot(self.profile_direction?)) + } + fn is_selection_gathering(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn selection_forces_add(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step != ExtrudeStep::Pick { + return; + } + self.handles = entities.iter().map(|entry| entry.handle).collect(); + self.preview_profiles = entities + .iter() + .map(|entry| (entry.handle, entry.entity.clone())) + .collect(); + if let Some(curve) = entities + .iter() + .find_map(|entry| crate::entities::curve::entity_curve(&entry.entity)) + { + self.anchor = DVec3::from_array(curve.plane.origin); + self.profile_direction = curve.plane.normal().map(DVec3::from_array); + } else { + self.profile_direction = None; + } + } + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + fn inject_before_entity_pick(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn inject_picked_entity(&mut self, entity: EntityType) { + if self.step == ExtrudeStep::Pick { + self.injected_profile = Some(entity); + } + } + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + self.preview_wires(cursor) } } @@ -479,6 +850,24 @@ pub fn empty_solid3d() -> EntityType { EntityType::Solid3D(Solid3D::new()) } +pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType { + use acadrust::entities::{Surface, SurfaceData, SurfaceKind}; + use acadrust::types::Vector3; + + let mut surface = Surface::new(SurfaceKind::Extruded); + if let SurfaceData::Extruded { + options, + sweep_vector, + .. + } = &mut surface.surface_data + { + options.draft_angle = taper_angle; + options.is_solid = false; + *sweep_vector = Vector3::new(direction.x, direction.y, direction.z); + } + EntityType::Surface(surface) +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE", "THICKEN", "PRESSPULL"] diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 1f09cf384..8bef33f11 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -6,7 +6,7 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use crate::t; -use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::draw::draw::polyline::{ arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc, }; @@ -464,14 +464,16 @@ impl CadCommand for MviewCommand { } } - fn wants_text_input(&self) -> bool { - self.step == Step::RectangleFirst - || self.step == Step::ChooseView + fn input_kind(&self) -> InputKind { + if self.step == Step::ChooseView { + InputKind::FreeText + } else if self.step == Step::RectangleFirst || (self.step == Step::Polygon && !self.polygon.is_empty()) - } - - fn wants_text_with_spaces(&self) -> bool { - self.step == Step::ChooseView + { + InputKind::SingleToken + } else { + InputKind::Point + } } fn point_step_accepts_keywords(&self) -> bool { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 76fdad524..ebfa32df8 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,5 +1,5 @@ use acadrust::entities::acis::{SabReader, SatBody, SatDocument}; -use acadrust::entities::{Body, Region, Solid3D}; +use acadrust::entities::{Body, Region, Solid3D, Surface}; use cadkernel::brep::{self, Body as KernelBody}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -156,10 +156,18 @@ fn parse_acis( } pub fn kernel_body(solid: &Solid3D) -> Option { + kernel_acis_body(&solid.acis_data) +} + +pub fn kernel_surface_body(surface: &Surface) -> Option { + kernel_acis_body(&surface.acis_data) +} + +fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { let sat = parse_acis( - || solid.parse_sat(), - solid.acis_data.is_binary, - &solid.acis_data.sab_data, + || acis.parse(), + acis.is_binary, + &acis.sab_data, )?; let (mut bodies, loss) = cadkernel::acis::lift(&sat); if !loss.is_empty() || bodies.len() != 1 { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 85f25954e..1fe0317b4 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -602,6 +602,9 @@ impl Scene { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } + Some(EntityType::Surface(surface)) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } _ => None, })?; Some((handle, body)) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 13f5bf6c2..21c7be850 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2725,12 +2725,21 @@ impl Scene { if let Some((mut set, wires, center)) = crate::scene::model::solid_model::display_from_solid(&solid, color) { - if let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) { - entity.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - entity.wires = wires; - entity.silhouettes.clear(); + if let Some(entity) = self.document.get_entity_mut(handle) { + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match entity { + EntityType::Solid3D(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); + } + EntityType::Surface(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); + } + _ => {} + } } let name = handle.value().to_string(); for mesh in &mut set.lods { @@ -8206,6 +8215,60 @@ impl Scene { ) } + /// Normal object selection for shaded 3D bodies is edge based. This keeps + /// the broad face interior available to modelling commands without making + /// it part of ordinary object picking. + pub fn solid_edge_click_hit( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, + tolerance_px: f32, + ) -> Option { + let meshes = self.interaction_meshes_arc(); + pick::hit_test::mesh_edge_click_hit( + cursor, + meshes.iter().filter_map(|set| { + let handle = set.entity_handle()?; + if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { + return None; + } + let (edges, edges_low) = set.geometry_edges(); + (!edges.is_empty()).then_some(( + handle, + edges, + edges_low, + set.instance_transform, + )) + }), + view_rot, + eye, + bounds, + tolerance_px, + ) + } + + pub fn solid_edge_hover_hit( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, + tolerance_px: f32, + ) -> Option { + self.solid_edge_click_hit( + cursor, + view_rot, + eye, + bounds, + candidate_handles, + tolerance_px, + ) + } + pub fn solid_click_point_for( &self, cursor: iced::Point, diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 97ee55441..628b7c408 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -274,6 +274,16 @@ impl MeshLodSet { .map_or(self.lods.as_slice(), |source| source.lods.as_slice()) } + pub fn geometry_edges(&self) -> (&[[f32; 3]], &[[f32; 3]]) { + self.instance_source.as_ref().map_or( + (self.edge_verts.as_slice(), self.edge_verts_low.as_slice()), + |source| ( + source.edge_verts.as_slice(), + source.edge_verts_low.as_slice(), + ), + ) + } + pub fn entity_handle(&self) -> Option { self.instance_handle.or_else(|| { self.lods diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 68b23d0f8..c32d63dd1 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,7 +1,7 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, + SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; @@ -25,9 +25,13 @@ pub const GRIP_SIDES: usize = 10_007; pub const GRIP_MAJOR_RADIUS: usize = 10_008; pub const GRIP_MINOR_RADIUS: usize = 10_009; pub const GRIP_TOP_RADIUS: usize = 10_010; -pub const GRIP_POSITION: usize = 10_011; -pub const GRIP_REVOLVE_AXIS: usize = 10_012; +pub const GRIP_DRAFT: usize = 10_012; +pub const GRIP_PROFILE_FIRST: usize = 10_200; +pub const GRIP_SWEEP_PROFILE_FIRST: usize = 20_000; +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_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; @@ -62,6 +66,8 @@ pub const PROP_BANK: &str = "solid_history_bank"; pub const PROP_TWIST_ALONG_PATH: &str = "solid_history_twist_along_path"; pub const PROP_SCALE_ALONG_PATH: &str = "solid_history_scale_along_path"; pub const PROP_SWEEP_LENGTH: &str = "solid_history_sweep_length"; +pub const PROP_EXTRUSION_HEIGHT: &str = "solid_history_extrusion_height"; +pub const PROP_TAPER_ANGLE: &str = "solid_history_taper_angle"; pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -115,6 +121,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Torus(_) | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) + | SolidHistoryOperation::Extrusion(_) ) ) } @@ -130,7 +137,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]), - SolidHistoryOperation::Sweep(value) => world_point( + SolidHistoryOperation::Sweep(value) | SolidHistoryOperation::Extrusion(value) => world_point( value.base.transform, [ value.reference_point.x, @@ -144,6 +151,100 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } } +fn extrusion_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistorySweep, +) -> Vec { + let position = world_point( + value.base.transform, + [ + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + ], + ) + .unwrap_or(glam::DVec3::ZERO); + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let path_length = sweep_path_length(value); + let height_value = path_length.unwrap_or(value.end_draft_distance); + let height = if value.path_entity.is_some() { + PropValue::ReadOnly(crate::entities::common::format_length(height_value)) + } else { + PropValue::EditText(crate::entities::common::format_length(height_value)) + }; + let taper = if value.path_entity.is_some() { + PropValue::ReadOnly(crate::entities::common::format_angle(value.draft_angle)) + } else { + PropValue::EditText(crate::entities::common::format_angle(value.draft_angle)) + }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Extrusion").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + Property { + label: t!("Height").into_owned(), + field: PROP_EXTRUSION_HEIGHT, + value: height, + }, + Property { + label: t!("Taper angle").into_owned(), + field: PROP_TAPER_ANGLE, + value: taper, + }, + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: if show_history { "Yes" } else { "No" }.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) + }, + }, + ], + }, + ] +} + fn sweep_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -231,7 +332,30 @@ fn sweep_path_length(value: &SolidHistorySweep) -> Option { use acadrust::entities::EmbeddedEntity; use acadrust::EntityType; - let entity = match value.path_entity.as_ref()? { + let path_entity = value.path_entity.as_ref()?; + if let EmbeddedEntity::Spline(path) = path_entity { + if path.degree == 1 && path.control_points.len() >= 2 && path.weights.is_empty() { + let transform = glam::DMat4::from_cols_array(&value.path_entity_transform); + if !transform.is_finite() || transform.determinant().abs() <= 1e-12 { + return None; + } + return Some( + path.control_points + .windows(2) + .map(|pair| { + let start = transform.transform_point3(glam::DVec3::new( + pair[0].x, pair[0].y, pair[0].z, + )); + let end = transform.transform_point3(glam::DVec3::new( + pair[1].x, pair[1].y, pair[1].z, + )); + end.distance(start) + }) + .sum(), + ); + } + } + let entity = match path_entity { EmbeddedEntity::Line(value) => EntityType::Line(value.clone()), EmbeddedEntity::Arc(value) => EntityType::Arc(value.clone()), EmbeddedEntity::Circle(value) => EntityType::Circle(value.clone()), @@ -894,6 +1018,9 @@ pub fn primitive_properties( SolidHistoryOperation::Pyramid(value) => pyramid_properties(document, handle, value), SolidHistoryOperation::Torus(value) => torus_properties(document, handle, value), SolidHistoryOperation::Sweep(value) => sweep_properties(document, handle, value), + SolidHistoryOperation::Extrusion(value) => { + extrusion_properties(document, handle, value) + } _ => Vec::new(), } } @@ -924,6 +1051,8 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_PROFILE_ROTATION | PROP_TWIST_ALONG_PATH | PROP_SCALE_ALONG_PATH + | PROP_EXTRUSION_HEIGHT + | PROP_TAPER_ANGLE | PROP_PYRAMID_TYPE ) } @@ -938,6 +1067,81 @@ pub fn is_specialized_property(field: &str) -> bool { || is_history_choice(field) } +fn apply_extrusion_geometry_property( + value: &mut SolidHistorySweep, + field: &str, + text: &str, +) -> Option { + if value.path_entity.is_some() + && matches!(field, PROP_EXTRUSION_HEIGHT | PROP_TAPER_ANGLE) + { + return Some(false); + } + match field { + PROP_POSITION_X | PROP_POSITION_Y | PROP_POSITION_Z => { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + _ => 2, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let reference = current.transform_point3(glam::DVec3::new( + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + )); + if !reference.is_finite() || (target - reference[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - reference[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) + } + PROP_EXTRUSION_HEIGHT => { + let height = crate::entities::common::parse_length(text)?; + if !height.is_finite() || height <= 1e-9 { + return Some(false); + } + let direction = glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + ); + let Some(unit) = direction.try_normalize() else { + return Some(false); + }; + let updated = unit * height; + if (updated - direction).length_squared() <= 1e-24 { + return Some(false); + } + value.direction = acadrust::types::Vector3::new(updated.x, updated.y, updated.z); + value.end_draft_distance = height; + Some(true) + } + PROP_TAPER_ANGLE => { + let angle = crate::entities::common::parse_angle(text)?; + if !angle.is_finite() + || angle.abs() >= std::f64::consts::FRAC_PI_2 + || (angle - value.draft_angle).abs() <= 1e-12 + { + return Some(false); + } + value.draft_angle = angle; + Some(true) + } + _ => None, + } +} + pub fn apply_history_choice( document: &mut acadrust::CadDocument, handle: acadrust::Handle, @@ -1367,6 +1571,13 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Extrusion(extrusion_value) = operation { + if let Some(applied) = + apply_extrusion_geometry_property(extrusion_value, field, value) + { + return applied; + } + } if let SolidHistoryOperation::Torus(torus_value) = operation { if let Some(applied) = apply_torus_position_property(torus_value, field, value) { return applied; @@ -1676,6 +1887,268 @@ fn into_embedded_entity(value: EntityType) -> Option { }) } +fn profile_matrix(value: &SolidHistorySweep) -> Option { + Some(matrix(value.base.transform)? * matrix(value.sweep_entity_transform)?) +} + +fn extrusion_profile_grips(value: &SolidHistorySweep) -> (Vec, Option) { + let Some(profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else { + return (Vec::new(), None); + }; + let Some(transform) = profile_matrix(value) else { + return (Vec::new(), None); + }; + let source = profile.grips(); + if source.is_empty() { + return (Vec::new(), None); + } + + let mut center = glam::DVec3::ZERO; + let mut count = 0.0; + let grips = source + .into_iter() + .enumerate() + .filter_map(|(index, grip)| { + let world = transform.transform_point3(grip.world); + if !world.is_finite() { + return None; + } + center += world; + count += 1.0; + let transform_vector = |vector: glam::DVec3| { + let vector = transform.transform_vector3(vector); + (vector.length_squared() > 1e-12).then(|| vector.normalize()) + }; + Some(GripDef { + id: GRIP_PROFILE_FIRST + index, + world, + is_midpoint: grip.is_midpoint, + shape: grip.shape, + dir: grip.dir.and_then(transform_vector), + axis: grip.axis.and_then(transform_vector), + }) + }) + .collect::>(); + let center = (count > 0.0).then_some(center / count); + (grips, center) +} + +fn apply_extrusion_profile_grip( + value: &mut SolidHistorySweep, + index: usize, + apply: GripApply, +) -> bool { + let Some(mut profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else { + return false; + }; + let Some(source_grip) = profile.grips().get(index).cloned() else { + return false; + }; + let Some(transform) = profile_matrix(value) else { + return false; + }; + let inverse = transform.inverse(); + if !inverse.is_finite() { + return false; + } + let local_apply = match apply { + GripApply::Absolute(world) => { + let local = inverse.transform_point3(world); + if !local.is_finite() { + return false; + } + GripApply::Absolute(local) + } + GripApply::Translate(world_delta) => { + let local_delta = inverse.transform_vector3(world_delta); + if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 { + return false; + } + GripApply::Translate(local_delta) + } + }; + profile.apply_grip(source_grip.id, local_apply); + let Some(profile) = into_embedded_entity(profile) else { + return false; + }; + value.sweep_entity = Some(profile); + true +} + +fn sweep_embedded_grips( + value: Option<&EmbeddedEntity>, + base_transform: [f64; 16], + entity_transform: [f64; 16], + first_id: usize, +) -> Vec { + let Some(entity) = value.and_then(embedded_entity) else { + return Vec::new(); + }; + let (Some(base), Some(entity_placement)) = + (matrix(base_transform), matrix(entity_transform)) + else { + return Vec::new(); + }; + let transform = base * entity_placement; + entity + .grips() + .into_iter() + .enumerate() + .filter_map(|(index, grip)| { + let world = transform.transform_point3(grip.world); + if !world.is_finite() { + return None; + } + let transform_vector = |vector: glam::DVec3| { + let vector = transform.transform_vector3(vector); + (vector.length_squared() > 1e-12).then(|| vector.normalize()) + }; + Some(GripDef { + id: first_id + index, + world, + is_midpoint: grip.is_midpoint, + shape: grip.shape, + dir: grip.dir.and_then(transform_vector), + axis: grip.axis.and_then(transform_vector), + }) + }) + .collect() +} + +fn apply_sweep_embedded_grip( + value: &mut Option, + base_transform: [f64; 16], + entity_transform: [f64; 16], + index: usize, + apply: GripApply, +) -> bool { + let Some(mut entity) = value.as_ref().and_then(embedded_entity) else { + return false; + }; + let Some(source_grip) = entity.grips().get(index).cloned() else { + return false; + }; + let (Some(base), Some(entity_placement)) = + (matrix(base_transform), matrix(entity_transform)) + else { + return false; + }; + let inverse = (base * entity_placement).inverse(); + if !inverse.is_finite() { + return false; + } + let apply = match apply { + GripApply::Absolute(world) => { + let local = inverse.transform_point3(world); + if !local.is_finite() { + return false; + } + GripApply::Absolute(local) + } + GripApply::Translate(world_delta) => { + let local_delta = inverse.transform_vector3(world_delta); + if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 { + return false; + } + GripApply::Translate(local_delta) + } + }; + entity.apply_grip(source_grip.id, apply); + let Some(entity) = into_embedded_entity(entity) else { + return false; + }; + *value = Some(entity); + true +} + +fn loft_section_grips(value: &SolidHistoryLoft) -> Vec { + if !value.guides.is_empty() { + return Vec::new(); + } + let Some(transform) = matrix(value.base.transform) else { + return Vec::new(); + }; + let mut result = Vec::new(); + let mut index = 0; + for section in &value.cross_sections { + let Some(entity) = embedded_entity(section) else { + continue; + }; + for source in entity.grips() { + let id = GRIP_LOFT_SECTION_FIRST + index; + index += 1; + let world = transform.transform_point3(source.world); + if !world.is_finite() { + continue; + } + let transform_vector = |vector: glam::DVec3| { + let vector = transform.transform_vector3(vector); + (vector.length_squared() > 1e-12).then(|| vector.normalize()) + }; + result.push(GripDef { + id, + world, + is_midpoint: source.is_midpoint, + shape: source.shape, + dir: source.dir.and_then(transform_vector), + axis: source.axis.and_then(transform_vector), + }); + } + } + result +} + +fn apply_loft_section_grip( + value: &mut SolidHistoryLoft, + mut index: usize, + apply: GripApply, +) -> bool { + if !value.guides.is_empty() { + return false; + } + let Some(transform) = matrix(value.base.transform) else { + return false; + }; + let inverse = transform.inverse(); + if !inverse.is_finite() { + return false; + } + let local_apply = match apply { + GripApply::Absolute(world) => { + let local = inverse.transform_point3(world); + if !local.is_finite() { + return false; + } + GripApply::Absolute(local) + } + GripApply::Translate(world_delta) => { + let local_delta = inverse.transform_vector3(world_delta); + if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 { + return false; + } + GripApply::Translate(local_delta) + } + }; + for section in &mut value.cross_sections { + let Some(mut entity) = embedded_entity(section) else { + continue; + }; + let grips = entity.grips(); + if index >= grips.len() { + index -= grips.len(); + continue; + } + let grip_id = grips[index].id; + entity.apply_grip(grip_id, local_apply); + let Some(entity) = into_embedded_entity(entity) else { + return false; + }; + *section = entity; + return true; + } + false +} + fn revolve_profile_matrix(value: &SolidHistoryRevolve) -> Option { let base = matrix(value.base.transform)?; let axis_point = glam::DVec3::new( @@ -1684,8 +2157,8 @@ fn revolve_profile_matrix(value: &SolidHistoryRevolve) -> Option { value.axis_point.z, ); let axis = glam::DVec3::new(value.direction.x, value.direction.y, value.direction.z) - .normalize_or_zero(); - if !axis_point.is_finite() || axis.length_squared() <= 1e-12 { + .try_normalize()?; + if !axis_point.is_finite() || !value.start_angle.is_finite() { return None; } Some( @@ -1770,6 +2243,96 @@ fn apply_revolve_profile_grip( true } +fn extrusion_draft_grip( + value: &SolidHistorySweep, +) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, f64, f64)> { + if value.path_entity.is_some() { + return None; + } + let (profile_grips, center) = extrusion_profile_grips(value); + let center = center?; + let transform = profile_matrix(value)?; + let inverse = transform.inverse(); + if !inverse.is_finite() { + return None; + } + let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; + let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; + let normal = transform + .transform_vector3(glam::DVec3::from_array(normal)) + .try_normalize()?; + let center_local = inverse.transform_point3(center); + let anchor = profile_grips + .into_iter() + .filter_map(|grip| { + let local = inverse.transform_point3(grip.world); + let radius = (grip.world - center).length_squared(); + (local.is_finite() && radius > 1e-12).then_some((grip.world, local)) + }) + .min_by(|(_, a), (_, b)| { + a.x.total_cmp(&b.x) + .then_with(|| { + (a.y - center_local.y) + .abs() + .total_cmp(&(b.y - center_local.y).abs()) + }) + .then_with(|| { + (a.z - center_local.z) + .abs() + .total_cmp(&(b.z - center_local.z).abs()) + }) + })? + .0; + let radial = anchor - center; + let radial_length = radial.length(); + if !radial_length.is_finite() || radial_length <= 1e-6 { + return None; + } + let radial = radial / radial_length; + let base = matrix(value.base.transform)?; + let direction = base.transform_vector3(glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + )); + let height = direction.dot(normal).abs(); + if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { + return None; + } + let draft_offset = -height * value.draft_angle.tan(); + if !draft_offset.is_finite() { + return None; + } + let base_top = anchor + direction; + let world = base_top + radial * draft_offset; + world + .is_finite() + .then_some((world, base_top, radial, radial_length, height)) +} + +fn apply_extrusion_draft_grip(value: &mut SolidHistorySweep, apply: GripApply) -> bool { + let GripApply::Absolute(world) = apply else { + return false; + }; + let Some((_, base_top, radial, profile_radius, height)) = extrusion_draft_grip(value) else { + return false; + }; + let top_offset = (world - base_top).dot(radial); + if !top_offset.is_finite() { + return false; + } + let limit = 89.0_f64.to_radians(); + let inward_limit = ((profile_radius - 1e-6).max(1e-6) / height) + .atan() + .min(limit); + let angle = (-top_offset).atan2(height).clamp(-limit, inward_limit); + if !angle.is_finite() || (angle - value.draft_angle).abs() <= 1e-12 { + return false; + } + value.draft_angle = angle; + true +} + fn grip( id: usize, world: glam::DVec3, @@ -1991,17 +2554,21 @@ pub fn primitive_grips( None, ); } - SolidHistoryOperation::Sweep(value) => add( - GRIP_POSITION, - value.base.transform, - [ - value.reference_point.x, - value.reference_point.y, - value.reference_point.z, - ], - GripShape::Square, - None, - ), + SolidHistoryOperation::Sweep(value) => { + grips.extend(sweep_embedded_grips( + value.sweep_entity.as_ref(), + value.base.transform, + value.sweep_entity_transform, + GRIP_SWEEP_PROFILE_FIRST, + )); + grips.extend(sweep_embedded_grips( + value.path_entity.as_ref(), + value.base.transform, + value.path_entity_transform, + GRIP_SWEEP_PATH_FIRST, + )); + } + SolidHistoryOperation::Loft(value) => grips.extend(loft_section_grips(value)), SolidHistoryOperation::Revolve(value) => { add( GRIP_REVOLVE_AXIS, @@ -2012,6 +2579,32 @@ pub fn primitive_grips( ); grips.extend(revolve_profile_grips(value)); } + SolidHistoryOperation::Extrusion(value) => { + let (profile_grips, profile_center) = extrusion_profile_grips(value); + grips.extend(profile_grips); + let direction = [value.direction.x, value.direction.y, value.direction.z]; + if value.path_entity.is_none() { + if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { + let direction = base.transform_vector3(glam::DVec3::from_array(direction)); + if direction.length_squared() > 1e-12 { + grips.push(grip( + GRIP_HEIGHT, + center + direction, + GripShape::Triangle, + Some(direction.normalize()), + )); + } + } + if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) { + grips.push(grip( + GRIP_DRAFT, + world, + GripShape::Triangle, + Some(radial), + )); + } + } + } _ => {} } grips @@ -2027,6 +2620,42 @@ pub fn apply_primitive_grip( return apply_revolve_profile_grip(value, index, apply); } } + if let SolidHistoryOperation::Loft(value) = operation { + if let Some(index) = grip_id.checked_sub(GRIP_LOFT_SECTION_FIRST) { + return apply_loft_section_grip(value, index, apply); + } + } + if let SolidHistoryOperation::Sweep(value) = operation { + if let Some(index) = grip_id.checked_sub(GRIP_SWEEP_PATH_FIRST) { + return apply_sweep_embedded_grip( + &mut value.path_entity, + value.base.transform, + value.path_entity_transform, + index, + apply, + ); + } + if let Some(index) = grip_id + .checked_sub(GRIP_SWEEP_PROFILE_FIRST) + .filter(|_| grip_id < GRIP_SWEEP_PATH_FIRST) + { + return apply_sweep_embedded_grip( + &mut value.sweep_entity, + value.base.transform, + value.sweep_entity_transform, + index, + apply, + ); + } + } + if let SolidHistoryOperation::Extrusion(value) = operation { + if let Some(index) = grip_id.checked_sub(GRIP_PROFILE_FIRST) { + return apply_extrusion_profile_grip(value, index, apply); + } + if grip_id == GRIP_DRAFT { + return apply_extrusion_draft_grip(value, apply); + } + } let GripApply::Absolute(world) = apply else { return false; }; @@ -2160,25 +2789,44 @@ pub fn apply_primitive_grip( } _ => return false, }, - SolidHistoryOperation::Sweep(value) if grip_id == GRIP_POSITION => { - let Some(current) = matrix(value.base.transform) else { - return false; - }; - let reference = current.transform_point3(glam::DVec3::new( - value.reference_point.x, - value.reference_point.y, - value.reference_point.z, - )); - let delta = world - reference; - if !delta.is_finite() || delta.length_squared() <= 1e-24 { - return false; - } - value.base.transform = - (glam::DMat4::from_translation(delta) * current).to_cols_array(); - } SolidHistoryOperation::Revolve(value) if grip_id == GRIP_REVOLVE_AXIS => { value.axis_point = acadrust::types::Vector3::new(local.x, local.y, local.z); } + SolidHistoryOperation::Extrusion(value) => match grip_id { + GRIP_HEIGHT => { + if value.path_entity.is_some() { + return false; + } + let (_, Some(profile_center)) = extrusion_profile_grips(value) else { + return false; + }; + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let direction = current + .inverse() + .transform_vector3(world - profile_center); + let height = direction.length(); + if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { + return false; + } + let current = glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + ); + if (direction - current).length_squared() <= 1e-24 { + return false; + } + value.direction = acadrust::types::Vector3::new( + direction.x, + direction.y, + direction.z, + ); + value.end_draft_distance = height; + } + _ => return false, + }, _ => return false, } true diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index aa96bf6a5..f7ff2456d 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -3,9 +3,9 @@ use cadkernel::brep::{self, Body}; use cadkernel::geom2d::{Arc, Curve, EllipseArc, Line}; use cadkernel::space::{PlanarCurve, Plane, Vec3}; -use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex}; +use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex, Spline}; use acadrust::objects::{ - SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryRevolve, SolidHistorySweep, + SolidHistoryLoft, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep, }; use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; @@ -28,30 +28,185 @@ pub struct Profile { /// is handed over as the arcs it is made of rather than as one curve with /// nowhere for the sweep to start. pub fn profile_of(entity: &EntityType) -> Option { - let planar: PlanarCurve = entity_curve(entity)?; + let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; if !planar.curve.is_closed() { return None; } - let pieces = match &planar.curve { - Curve::Polyline(_) => planar.curve.segments(), - Curve::Circle(circle) => quarters(circle.centre, circle.radius), - Curve::Arc(arc) => split_arc(*arc), - Curve::Ellipse(arc) => split_ellipse(*arc), - Curve::Nurbs(curve) => (0..4) - .map(|part| { - curve - .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) - .map(Curve::Nurbs) - }) - .collect::>>()?, - _ => return None, - }; + let pieces = profile_pieces(&planar.curve, true)?; (pieces.len() >= 3).then_some(Profile { plane: planar.plane, pieces, }) } +/// An entity-level planar curve suitable for either a solid profile or an +/// open surface profile. +pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> { + let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; + let closed = planar.curve.is_closed(); + let pieces = profile_pieces(&planar.curve, closed)?; + (!pieces.is_empty()).then_some(( + Profile { + plane: planar.plane, + pieces, + }, + closed, + )) +} + +fn profile_pieces(curve: &Curve, closed: bool) -> Option> { + let pieces = match curve { + Curve::Polyline(_) => curve.segments(), + Curve::Circle(circle) => quarters(circle.centre, circle.radius), + Curve::Arc(arc) if closed => split_arc(*arc), + Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Ellipse(arc) if closed => split_ellipse(*arc), + Curve::Ellipse(arc) => vec![Curve::Ellipse(*arc)], + Curve::Line(line) => vec![Curve::Line(*line)], + Curve::Nurbs(nurbs) if closed => (0..4) + .map(|part| { + nurbs + .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) + .map(Curve::Nurbs) + }) + .collect::>>()?, + Curve::Nurbs(nurbs) => vec![Curve::Nurbs(nurbs.clone())], + _ => return None, + }; + Some(pieces) +} + +fn planar_polygon_entity(entity: &EntityType) -> Option { + let (points, closed) = match entity { + EntityType::Polyline3D(value) => ( + value + .vertices + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect::>(), + value.is_closed(), + ), + EntityType::Face3D(value) => ( + value + .corners() + .into_iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + EntityType::Solid(value) => ( + value + .boundary_corners() + .into_iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + EntityType::Region(value) => ( + value + .wires + .first()? + .points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + _ => return None, + }; + let mut points = points; + if closed && points.len() > 2 && points.first() == points.last() { + points.pop(); + } + if points.len() < if closed { 3 } else { 2 } { + return None; + } + let origin = Vec3::from(points[0]); + let first = Vec3::from(points[1]) - origin; + let normal = points[2..] + .iter() + .find_map(|point| first.cross(Vec3::from(*point) - origin).normalize()) + .or_else(|| { + (!closed).then(|| first.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y)) + })?; + let plane = Plane::orthonormal(points[0], first.to_array(), normal.to_array())?; + let scale = points + .iter() + .flatten() + .map(|value| value.abs()) + .fold(1.0_f64, f64::max); + if points + .iter() + .any(|point| !plane.contains(*point, scale * 1e-9)) + { + return None; + } + let vertices = points + .iter() + .map(|point| { + plane.project(*point).map(|position| cadkernel::geom2d::PolylineVertex { + position, + bulge: 0.0, + }) + }) + .collect::>>()?; + Some(PlanarCurve::new( + plane, + Curve::Polyline(cadkernel::geom2d::Polyline { vertices, closed }), + )) +} + +fn region_profiles(entity: &EntityType) -> Option<(Plane, Vec>)> { + let EntityType::Region(region) = entity else { + return None; + }; + let plane = planar_polygon_entity(entity)?.plane; + let mut profiles = Vec::with_capacity(region.wires.len()); + for wire in ®ion.wires { + let mut points = wire + .points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(); + if points.len() > 2 && points.first() == points.last() { + points.pop(); + } + if points.len() < 3 { + return None; + } + let scale = points + .iter() + .flatten() + .map(|value| value.abs()) + .fold(1.0_f64, f64::max); + if points + .iter() + .any(|point| !plane.contains(*point, scale * 1e-9)) + { + return None; + } + let vertices = points + .iter() + .map(|point| { + plane + .project(*point) + .map(|position| cadkernel::geom2d::PolylineVertex { + position, + bulge: 0.0, + }) + }) + .collect::>>()?; + profiles.push( + Curve::Polyline(cadkernel::geom2d::Polyline { + vertices, + closed: true, + }) + .segments(), + ); + } + (!profiles.is_empty()).then_some((plane, profiles)) +} + /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { use std::f64::consts::FRAC_PI_2; @@ -103,10 +258,112 @@ fn split_ellipse(arc: EllipseArc) -> Vec { pub fn extruded(entity: &EntityType, height: f64) -> Option { let profile = profile_of(entity)?; let normal = profile.plane.normal()?; - brep::extrude( + extruded_direction( + entity, + [normal[0] * height, normal[1] * height, normal[2] * height], + 0.0, + ) +} + +pub fn extruded_direction( + entity: &EntityType, + direction: [f64; 3], + taper_angle: f64, +) -> Option { + if let Some((plane, profiles)) = + region_profiles(entity).filter(|(_, profiles)| profiles.len() > 1) + { + if taper_angle.abs() > 1e-12 { + return None; + } + return brep::extrude_region(plane, &profiles, direction); + } + let profile = profile_of(entity)?; + brep::extrude_tapered(profile.plane, &profile.pieces, direction, taper_angle) +} + +pub fn extruded_surface( + entity: &EntityType, + direction: [f64; 3], + taper_angle: f64, +) -> Option { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + return None; + } + let (profile, _) = extrusion_profile_of(entity)?; + brep::extrude_surface_tapered( profile.plane, &profile.pieces, - [normal[0] * height, normal[1] * height, normal[2] * height], + direction, + taper_angle, + ) +} + +pub fn extruded_along_path( + entity: &EntityType, + path: &EntityType, + taper_angle: f64, +) -> Option { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + if taper_angle.abs() > 1e-12 { + return None; + } + return extruded_direction(entity, straight_path_direction(path)?, 0.0); + } + let profile = profile_of(entity)?; + if let EntityType::Polyline3D(path) = path { + let points = path + .vertices + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect::>(); + if taper_angle.abs() > 1e-12 { + if points.len() != 2 { + return None; + } + return brep::extrude_tapered( + profile.plane, + &profile.pieces, + (Vec3::from(points[1]) - Vec3::from(points[0])).to_array(), + taper_angle, + ); + } + return brep::sweep_along_polyline3d(profile.plane, &profile.pieces, &points); + } + if taper_angle.abs() <= 1e-12 { + return swept(entity, path); + } + let curve = entity_curve(path)?; + let Curve::Line(line) = curve.curve else { + return None; + }; + let direction = Vec3::from(curve.plane.point_at(line.end)) + - Vec3::from(curve.plane.point_at(line.start)); + brep::extrude_tapered( + profile.plane, + &profile.pieces, + direction.to_array(), + taper_angle, + ) +} + +pub fn straight_path_direction(path: &EntityType) -> Option<[f64; 3]> { + if let EntityType::Polyline3D(path) = path { + if path.vertices.len() != 2 { + return None; + } + let start = &path.vertices[0].position; + let end = &path.vertices[1].position; + return Some([end.x - start.x, end.y - start.y, end.z - start.z]); + } + let curve = entity_curve(path)?; + let Curve::Line(line) = curve.curve else { + return None; + }; + Some( + (Vec3::from(curve.plane.point_at(line.end)) + - Vec3::from(curve.plane.point_at(line.start))) + .to_array(), ) } @@ -139,26 +396,13 @@ pub fn revolved( ) } -fn profile_bounds_center(profile: &Profile) -> Option { - let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); - let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - let mut found = false; - for piece in &profile.pieces { - for point in piece.tessellate_within(1e-4) { - let point = Vec3::from(profile.plane.point_at(point)); - if !point.is_finite() { - continue; - } - low.x = low.x.min(point.x); - low.y = low.y.min(point.y); - low.z = low.z.min(point.z); - high.x = high.x.max(point.x); - high.y = high.y.max(point.y); - high.z = high.z.max(point.z); - found = true; - } - } - found.then(|| (low + high) * 0.5) +fn profile_center(profile: &Profile) -> Option { + let count = profile.pieces.len(); + let sum = profile.pieces.iter().try_fold(Vec3::ZERO, |sum, piece| { + let point = Vec3::from(profile.plane.point_at(piece.point_at(0.0))); + point.is_finite().then_some(sum + point) + })?; + (count > 0).then_some(sum / count as f64) } /// Stores the source profile and canonical axis required to rebuild and edit @@ -175,7 +419,7 @@ pub fn revolve_history( let profile = profile_of(entity)?; let from = Vec3::from(from); let direction = (Vec3::from(to) - from).normalize()?; - let center = profile_bounds_center(&profile)?; + let center = profile_center(&profile)?; let axis_point = from + direction * (center - from).dot(direction); let mut base = SolidHistoryNodeBase::new(1); base.transform = glam::DMat4::IDENTITY.to_cols_array(); @@ -194,13 +438,40 @@ pub fn revolve_history( /// SWEEP as an exact B-rep along straight and circular path pieces. pub fn swept(profile: &EntityType, path: &EntityType) -> Option { let profile = profile_of(profile)?; - let path = entity_curve(path)?; + let mut path = entity_curve(path)?; let pieces = match &path.curve { Curve::Line(line) => vec![Curve::Line(*line)], Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Circle(circle) => quarters(circle.centre, circle.radius), Curve::Polyline(_) => path.curve.segments(), + Curve::Ellipse(_) | Curve::Nurbs(_) => { + let scale = path + .curve + .point_at(0.0) + .into_iter() + .chain(path.curve.point_at(1.0)) + .map(f64::abs) + .fold(1.0_f64, f64::max); + path.curve + .tessellate_within(scale * 1e-4) + .windows(2) + .filter(|pair| pair[0] != pair[1]) + .map(|pair| Curve::Line(Line { + start: pair[0], + end: pair[1], + })) + .collect() + } _ => return None, }; + let profile_center = profile + .pieces + .iter() + .map(|piece| Vec3::from(profile.plane.point_at(piece.point_at(0.0)))) + .fold(Vec3::ZERO, |sum, point| sum + point) + / profile.pieces.len() as f64; + let path_start = Vec3::from(path.plane.point_at(path.curve.point_at(0.0))); + path.plane.origin = (Vec3::from(path.plane.origin) + profile_center - path_start).to_array(); brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces) } @@ -219,6 +490,26 @@ pub fn lofted(profiles: &[EntityType]) -> Option { brep::loft(§ions) } +/// Stores the source sections required to rebuild and edit a loft. +pub fn loft_history(profiles: &[EntityType]) -> Option { + let cross_sections = profiles + .iter() + .map(embedded_path) + .collect::>>()?; + if cross_sections.len() < 2 { + return None; + } + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + Some(SolidHistoryOperation::Loft(SolidHistoryLoft { + base, + operation_major: 1, + cross_sections, + guides: Vec::new(), + ..SolidHistoryLoft::default() + })) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PolysolidJustification { Left, @@ -226,7 +517,7 @@ pub enum PolysolidJustification { Right, } -fn embedded_path(entity: &EntityType) -> Option { +pub fn embedded_path(entity: &EntityType) -> Option { match entity { EntityType::Line(value) => Some(EmbeddedEntity::Line(value.clone())), EntityType::Arc(value) => Some(EmbeddedEntity::Arc(value.clone())), @@ -234,10 +525,143 @@ fn embedded_path(entity: &EntityType) -> Option { EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())), EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())), EntityType::Spline(value) => Some(EmbeddedEntity::Spline(value.clone())), + EntityType::Polyline3D(value) if !value.is_closed() && value.vertices.len() >= 2 => { + let mut spline = Spline::from_control_points( + 1, + value + .vertices + .iter() + .map(|vertex| vertex.position) + .collect(), + ); + spline.flags.linear = true; + spline.flags.planar = false; + Some(EmbeddedEntity::Spline(spline)) + } _ => None, } } +fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + return None; + } + if !matches!(entity, EntityType::Polyline3D(_)) { + if let Some(embedded) = embedded_path(entity) { + return Some((embedded, glam::DMat4::IDENTITY.to_cols_array())); + } + } + let (profile, closed) = extrusion_profile_of(entity)?; + let mut polyline = LwPolyline::new(); + for piece in &profile.pieces { + let Curve::Line(line) = piece else { + return None; + }; + polyline + .vertices + .push(LwVertex::new(Vector2::new(line.start[0], line.start[1]))); + } + if !closed { + let Curve::Line(last) = profile.pieces.last()? else { + return None; + }; + polyline + .vertices + .push(LwVertex::new(Vector2::new(last.end[0], last.end[1]))); + } + polyline.is_closed = closed; + let normal = profile.plane.normal()?; + let transform = glam::DMat4::from_cols( + glam::DVec4::new( + profile.plane.x_axis[0], + profile.plane.x_axis[1], + profile.plane.x_axis[2], + 0.0, + ), + glam::DVec4::new( + profile.plane.y_axis[0], + profile.plane.y_axis[1], + profile.plane.y_axis[2], + 0.0, + ), + glam::DVec4::new(normal[0], normal[1], normal[2], 0.0), + glam::DVec4::new( + profile.plane.origin[0], + profile.plane.origin[1], + profile.plane.origin[2], + 1.0, + ), + ) + .to_cols_array(); + Some((EmbeddedEntity::LwPolyline(polyline), transform)) +} + +pub fn extrusion_history( + profile: &EntityType, + path: Option<&EntityType>, + direction: [f64; 3], + taper_angle: f64, + reference_point: [f64; 3], +) -> Option { + let signed_height = Vec3::from(direction).length(); + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?; + let (path_entity, path_entity_transform) = match path { + Some(path) => { + let (entity, transform) = embedded_planar_entity(path)?; + (Some(entity), transform) + } + None => (None, glam::DMat4::IDENTITY.to_cols_array()), + }; + Some(SolidHistoryOperation::Extrusion(SolidHistorySweep { + base, + operation_major: 1, + direction: Vector3::new(direction[0], direction[1], direction[2]), + sweep_entity: Some(sweep_entity), + path_entity, + draft_angle: taper_angle, + start_draft_distance: 0.0, + end_draft_distance: signed_height, + scale_factor: 1.0, + sweep_entity_transform, + path_entity_transform, + reference_point: Vector3::new( + reference_point[0], + reference_point[1], + reference_point[2], + ), + ..SolidHistorySweep::default() + })) +} + +pub fn sweep_history( + profile: &EntityType, + path: &EntityType, + taper_angle: f64, + reference_point: [f64; 3], +) -> Option { + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?; + Some(SolidHistoryOperation::Sweep(SolidHistorySweep { + base, + operation_major: 1, + sweep_entity: Some(sweep_entity), + path_entity: Some(embedded_path(path)?), + draft_angle: taper_angle, + scale_factor: 1.0, + sweep_entity_transform, + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new( + reference_point[0], + reference_point[1], + reference_point[2], + ), + ..SolidHistorySweep::default() + })) +} + /// Builds a history-backed wall solid along a supported planar curve. pub fn polysolid( entity: &EntityType, diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 857ff942f..209a88ba1 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -731,6 +731,92 @@ type MeshPickItem<'a> = ( [f64; 6], ); +type MeshEdgePickItem<'a> = ( + Handle, + &'a [[f32; 3]], + &'a [[f32; 3]], + Option, +); + +/// Return the closest solid whose actual B-rep feature edge passes through the +/// cursor aperture. Normal selection uses this path so a shaded body's broad +/// triangle faces do not turn its entire interior into a pick target. Commands +/// that deliberately acquire a face continue to use [`mesh_click_hit`]. +pub(crate) fn mesh_edge_click_hit<'a>( + cursor: Point, + meshes: impl Iterator>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, + tolerance_px: f32, +) -> Option { + if cursor.x < 0.0 + || cursor.x > bounds.width + || cursor.y < 0.0 + || cursor.y > bounds.height + { + return None; + } + let tolerance = tolerance_px.max(1.0); + let mut best: Option<(f32, f32, Handle)> = None; + for (handle, edges, edges_low, transform) in meshes { + if edges.len() < 2 { + continue; + } + let model = transform.map(codec_transform_matrix); + if model.is_some_and(|matrix| { + !matrix.is_finite() || matrix.determinant().abs() <= 1e-18 + }) { + continue; + } + for pair_start in (0..edges.len() - 1).step_by(2) { + let world_point = |index: usize| { + let hi = edges[index]; + let low = edges_low.get(index).copied().unwrap_or([0.0; 3]); + let local = glam::DVec3::new( + hi[0] as f64 + low[0] as f64, + hi[1] as f64 + low[1] as f64, + hi[2] as f64 + low[2] as f64, + ); + model.map_or(local, |matrix| matrix.transform_point3(local)) + }; + let first = world_point(pair_start); + let second = world_point(pair_start + 1); + if !first.is_finite() || !second.is_finite() { + continue; + } + let first_ndc = view_rot.project_point3((first - eye).as_vec3()); + let second_ndc = view_rot.project_point3((second - eye).as_vec3()); + if !first_ndc.is_finite() || !second_ndc.is_finite() { + continue; + } + let to_screen = |point: glam::Vec3| { + Point::new( + (point.x + 1.0) * 0.5 * bounds.width, + (1.0 - point.y) * 0.5 * bounds.height, + ) + }; + let distance = dist_point_to_segment( + cursor, + to_screen(first_ndc), + to_screen(second_ndc), + ); + if distance > tolerance { + continue; + } + let depth = (first_ndc.z + second_ndc.z) * 0.5; + let replace = best.is_none_or(|(best_distance, best_depth, _)| { + distance + 1e-4 < best_distance + || ((distance - best_distance).abs() <= 1e-4 && depth < best_depth) + }); + if replace { + best = Some((distance, depth, handle)); + } + } + } + best.map(|(_, _, handle)| handle) +} + pub(crate) fn mesh_click_hit<'a>( cursor: Point, meshes: impl Iterator>, diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 677b98618..21599ab0d 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -56,3 +56,24 @@ pub struct SelectionState { pub middle_last_pos: Option, pub middle_last_press_time: Option, } + +impl SelectionState { + /// End every left-button selection gesture without disturbing the previous + /// completed-window record or command-owned preview marquee. Grip editing + /// owns the left button while engaged and calls this before/after placement + /// so a small pointer move cannot also arm a box or lasso selection. + pub fn clear_left_selection_gesture(&mut self) { + self.left_down = false; + self.left_press_pos = None; + self.left_press_time = None; + self.left_dragging = false; + self.box_anchor = None; + self.box_anchor_world = None; + self.box_current = None; + self.box_crossing = false; + self.box_crossing_locked = false; + self.poly_active = false; + self.poly_points.clear(); + self.poly_crossing = false; + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 969f9de35..218e7d5a3 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3266,9 +3266,19 @@ impl Pipeline { &mut self, selected: &rustc_hash::FxHashSet, hovered: &rustc_hash::FxHashSet, + edge_wires: &[WireModel], ) { + let edge_handles: rustc_hash::FxHashSet = edge_wires + .iter() + .filter_map(|wire| wire.name.strip_prefix("mesh-edge:")) + .filter_map(|value| value.parse::().ok()) + .map(acadrust::Handle::new) + .collect(); let mut out = Vec::new(); - for handle in selected { + for handle in selected + .iter() + .filter(|handle| !edge_handles.contains(handle)) + { if let Some(ranges) = self.mesh_ranges_by_handle.get(handle) { out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw { range, @@ -3276,7 +3286,9 @@ impl Pipeline { })); } } - for handle in hovered.iter().filter(|handle| !selected.contains(handle)) { + for handle in hovered.iter().filter(|handle| { + !selected.contains(handle) && !edge_handles.contains(handle) + }) { if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) { out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw { range, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index de64fc1df..2365ab5b8 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1044,7 +1044,11 @@ impl shader::Primitive for Primitive { vp.selection_generation, ); if hl_key != inner.cached_highlight_key { - inner.update_mesh_highlight(&vp.selected_handles, &vp.hover_handles); + inner.update_mesh_highlight( + &vp.selected_handles, + &vp.hover_handles, + &vp.annotation_context_wires, + ); inner.cached_highlight_key = hl_key; } // Live overlay (command preview / interim / grip drag) — small and @@ -3324,6 +3328,7 @@ impl Scene { highlighted.sort_unstable_by_key(|(handle, _)| handle.value()); let empty_selection = rustc_hash::FxHashSet::default(); + let interaction_meshes = self.interaction_meshes_arc(); let mut wires = Vec::new(); for (handle, selected) in highlighted { let Some(entity) = self.document.get_entity(handle) else { @@ -3339,6 +3344,46 @@ impl Scene { continue; } + let tint = if selected { + WireModel::SELECTED + } else { + WireModel::HOVER + }; + for set in interaction_meshes + .iter() + .filter(|set| set.entity_handle() == Some(handle)) + { + let (edges, edges_low) = set.geometry_edges(); + if edges.len() < 2 { + continue; + } + let mut points = Vec::with_capacity(edges.len() / 2 * 3); + for pair_start in (0..edges.len() - 1).step_by(2) { + for index in [pair_start, pair_start + 1] { + let high = edges[index]; + let low = edges_low.get(index).copied().unwrap_or([0.0; 3]); + let local = acadrust::types::Vector3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ); + let world = set + .instance_transform + .map_or(local, |transform| transform.apply(local)); + points.push([world.x, world.y, world.z]); + } + points.push([f64::NAN; 3]); + } + let mut edge_wire = WireModel::solid_f64( + format!("mesh-edge:{}", handle.value()), + points, + tint, + selected, + ); + edge_wire.line_weight_px = 2.0; + wires.push(edge_wire); + } + if matches!(entity, EntityType::Hatch(_)) { if selected { if let Some(mut wire) = self.hatch_outline_wire(handle) { @@ -3390,12 +3435,6 @@ impl Scene { .map(|context| context.scale) }) .flatten(); - let tint = if selected { - WireModel::SELECTED - } else { - WireModel::HOVER - }; - for scale in scales { if displayed_scale == Some(scale) { continue;