From 66be530655fa86e504d367e2bec20c63369d0028 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:47:28 +0300 Subject: [PATCH] Complete automatic constraint behavior --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/command_driver.rs | 191 ++++++++++++++++-- src/app/commands/draw.rs | 101 ++++++---- src/app/commands/styleprops.rs | 49 ++++- src/app/mod.rs | 31 +++ src/app/properties.rs | 28 ++- src/app/settings.rs | 122 +++++++++++ src/app/update/command.rs | 40 +++- src/app/update/file.rs | 13 ++ src/app/update/mod.rs | 111 ++++++++++ src/app/update/viewport.rs | 135 ++++++++++++- src/app/view/mod.rs | 12 +- src/app/view/modal.rs | 15 ++ src/command.rs | 2 + src/modules/draw/select.rs | 37 +++- src/modules/parametric/mod.rs | 2 +- src/scene/mod.rs | 58 +++++- src/scene/parametric_constraints.rs | 246 +++++++++++++++++++---- src/scene/parametric_solve.rs | 218 +++++++++++++++++++- src/scene/selection.rs | 1 + src/scene/view/render.rs | 5 +- src/ui/overlay.rs | 27 ++- src/ui/window/auto_constrain_settings.rs | 143 +++++++++++++ src/ui/window/mod.rs | 1 + 25 files changed, 1494 insertions(+), 102 deletions(-) create mode 100644 src/ui/window/auto_constrain_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 7efbae01f..b706764fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 688a7c795..a3f180cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0600de34d..a689e8852 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1592,6 +1592,92 @@ impl OpenCADStudio { Task::none() } + fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { + let supported_creation = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); + if supported_creation { + self.apply_inferred_constraints(i, new_handles); + } + } + + pub(in crate::app) fn apply_inferred_constraints( + &mut self, + i: usize, + changed_handles: &[acadrust::Handle], + ) { + if !self.constraint_infer || changed_handles.is_empty() { + return; + } + let scope = self.tabs[i].current_parametric_scope(); + let owner = scope.owner_handle(&self.tabs[i].scene.document); + let candidates = self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.handle == owner) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &candidates, + &self.auto_constrain_settings, + ); + let inferred: Vec<_> = inferred + .into_iter() + .filter(|(_, refs)| { + refs.iter() + .any(|reference| changed_handles.contains(&reference.entity)) + }) + .collect(); + if inferred.is_empty() { + return; + } + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let mut touched = Vec::new(); + for (kind, refs) in inferred { + touched.extend(refs.iter().map(|reference| reference.entity)); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + touched.sort_unstable_by_key(|handle| handle.value()); + touched.dedup(); + let changes: Vec<_> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; if let Some(bind) = self.tabs[i] @@ -1613,6 +1699,15 @@ impl OpenCADStudio { .as_ref() .is_some_and(|command| command.preserve_commit_layer()); match result { + CmdResult::OpenAutoConstrainSettings => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(super::ModalKind::AutoConstrainSettings); + } CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to // the attribute editor dialog and end the command (open_attribute @@ -1720,6 +1815,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1760,15 +1858,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -1799,15 +1900,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2063,6 +2167,7 @@ impl OpenCADStudio { // transform_entities — always delta-safe. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); + self.apply_inferred_constraints(i, &handles); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2181,6 +2286,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2910,6 +3018,7 @@ impl OpenCADStudio { self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); self.tabs[i].scene.parametric_constraint_set_mut(scope).add( kind, refs, @@ -2919,7 +3028,11 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4467,6 +4580,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); let mut count = 0usize; let mut changed_handles = Vec::new(); + let mut driven_refs = Vec::new(); // Helper: is DXF point (x, y) inside the world-space window? // Drawing plane is world XY (= DXF XY). @@ -4485,6 +4599,54 @@ impl OpenCADStudio { let mut stretched_dims: Vec = Vec::new(); for handle in &handles { let before = self.tabs[i].scene.document.get_entity_arc(*handle); + if let Some(entity) = before.as_deref() { + use crate::scene::parametric_constraints::ParametricRef; + match entity { + acadrust::EntityType::Line(line) => { + if in_win(line.start.x, line.start.y) { + driven_refs.push(ParametricRef::point(*handle, 0)); + } + if in_win(line.end.x, line.end.y) { + driven_refs.push(ParametricRef::point(*handle, 1)); + } + } + acadrust::EntityType::LwPolyline(polyline) => { + if let Some(world) = + crate::entities::curve::lwpolyline_world_xy(polyline) + { + for (index, vertex) in world.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + } + acadrust::EntityType::Polyline2D(polyline) => { + for (index, vertex) in polyline.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + acadrust::EntityType::Circle(circle) + if in_win(circle.center.x, circle.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + acadrust::EntityType::Arc(arc) + if in_win(arc.center.x, arc.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + _ => {} + } + } let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { continue; }; @@ -4707,7 +4869,12 @@ impl OpenCADStudio { .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + ); + self.apply_inferred_constraints(i, &changed_handles); } self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e45c8c1a4..7ef3f7b36 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1113,55 +1113,86 @@ impl OpenCADStudio { } } + "CONSTRAINTSETTINGS" => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(crate::app::ModalKind::AutoConstrainSettings); + } + "AUTOCONSTRAIN" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); + let sel = SelectObjectsCommand::auto_constrain(cmd); self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let scope = self.tabs[i].current_parametric_scope(); let inferred = self.tabs[i] .scene - .inferred_parametric_constraints(scope, &handles); + .inferred_parametric_constraints( + scope, + &handles, + &self.auto_constrain_settings, + ); if inferred.is_empty() { - self.command_line - .push_output("No supported geometric relations were found."); - return None; - } - let before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, + self.command_line.push_output( + format!( + "0 constraint(s) applied to {} object(s).", + handles.len() ) - }); - let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - let count = inferred.len(); - for (kind, refs) in inferred { + .as_str(), + ); + self.tabs[i].scene.deselect_all(); + self.refresh_selected_grips(); + } else { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .parametric_constraint_set_mut(scope) - .add(kind, refs, None); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(format!("{} constraint(s) applied.", count).as_str()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + .record_undo_parametric_constraints_before(scope, before); + let count = inferred.len(); + for (kind, refs) in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + format!( + "{} constraint(s) applied to {} object(s).", + count, + handles.len() + ) + .as_str(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 9fed425e9..751d2e5d2 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1046,6 +1046,9 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "CONSTRAINTSOLVEMODE" + | "CONSTRAINTINFER" + | "CONSTRAINTBARDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1068,7 +1071,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1262,6 +1265,50 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!( + name.as_str(), + "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + ) { + let current = match name.as_str() { + "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), + "CONSTRAINTINFER" => i16::from(self.constraint_infer), + "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, + _ => unreachable!(), + }; + let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 }; + if let Some(value) = &value { + match value + .parse::() + .ok() + .filter(|value| (0..=maximum).contains(value)) + { + Some(mode) => { + match name.as_str() { + "CONSTRAINTSOLVEMODE" => { + self.constraint_solve_mode = mode != 0 + } + "CONSTRAINTINFER" => self.constraint_infer = mode != 0, + "CONSTRAINTBARDISPLAY" => { + self.constraint_bar_display = mode + } + _ => unreachable!(), + } + self.persist_settings_if_changed(); + self.command_line + .push_output(&crate::tf!("{name} = {mode}")); + } + None => self.command_line.push_error(&crate::tf!( + "{name}: expected an integer from 0 to {maximum}." + )), + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "DONUTID" | "DONUTOD") { let current = if name == "DONUTID" { crate::modules::draw::defaults::get_donut_inner_diameter() diff --git a/src/app/mod.rs b/src/app/mod.rs index 50a864cf0..af96e2ac4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -556,6 +556,14 @@ pub(super) struct OpenCADStudio { /// glyph plus a driven value or named-parameter name. When false, every /// pill shows only the glyph. pub show_constraint_values: bool, + pub auto_constrain_settings: settings::AutoConstrainSettings, + auto_constrain_saved: Option, + auto_constrain_selected_row: usize, + auto_constrain_distance_input: String, + auto_constrain_angle_input: String, + pub constraint_solve_mode: bool, + pub constraint_infer: bool, + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -1722,6 +1730,7 @@ pub enum ModalKind { DrawingUnits, GeometricTolerance, DraftingSettings, + AutoConstrainSettings, LayerStateEditor, Plot, PrintAll, @@ -2640,6 +2649,20 @@ pub enum Message { DraftingSettingsClose, DraftingSettingsCloseDiscard, DraftingSettingsCloseKeep, + AutoConstrainSelectRow(usize), + AutoConstrainToggleKind(settings::AutoConstraintKind), + AutoConstrainMoveUp, + AutoConstrainMoveDown, + AutoConstrainSelectAll, + AutoConstrainClearAll, + AutoConstrainReset, + AutoConstrainToggleTangentPoint, + AutoConstrainTogglePerpendicularIntersection, + AutoConstrainDistanceChanged(String), + AutoConstrainAngleChanged(String), + AutoConstrainApply, + AutoConstrainOk, + AutoConstrainCancel, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). @@ -3631,6 +3654,14 @@ impl OpenCADStudio { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain_settings: settings::AutoConstrainSettings::default(), + auto_constrain_saved: None, + auto_constrain_selected_row: 0, + auto_constrain_distance_input: "0.05".to_string(), + auto_constrain_angle_input: "1".to_string(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 393467b2d..cb3b846be 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2772,6 +2772,25 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { + self.invalidate_property_targets_with_driven(i, handles, &[]); + } + + pub(super) fn invalidate_property_targets_with_driven( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + ) { + self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + } + + pub(super) fn invalidate_property_targets_with_originals( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retained_originals: &[(Handle, acadrust::EntityType)], + ) { let mut context_object_changed = false; for &handle in handles { // A dimension is drawn from the block holding its picture, and that @@ -2801,7 +2820,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities_with_parametric_originals( + &changes, + driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + retained_originals, + ); } /// Apply a single-property edit to every handle in `handles`, recording the diff --git a/src/app/settings.rs b/src/app/settings.rs index 4607b72d6..12cf09c9d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,103 @@ use crate::snap::SnapType; use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AutoConstraintKind { + Coincident, + Collinear, + Parallel, + Perpendicular, + Tangent, + Concentric, + Horizontal, + Vertical, + Equal, +} + +impl AutoConstraintKind { + pub const ALL: [Self; 9] = [ + Self::Coincident, + Self::Collinear, + Self::Parallel, + Self::Perpendicular, + Self::Tangent, + Self::Concentric, + Self::Horizontal, + Self::Vertical, + Self::Equal, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Collinear => "Collinear", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Tangent => "Tangent", + Self::Concentric => "Concentric", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Equal => "Equal", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AutoConstrainSettings { + pub priority: Vec, + pub enabled: Vec, + pub tangent_must_share_point: bool, + pub perpendicular_must_intersect: bool, + pub distance_tolerance: f64, + pub angle_tolerance_deg: f64, +} + +impl Default for AutoConstrainSettings { + fn default() -> Self { + Self { + priority: AutoConstraintKind::ALL.to_vec(), + enabled: AutoConstraintKind::ALL.to_vec(), + tangent_must_share_point: true, + perpendicular_must_intersect: true, + distance_tolerance: 0.05, + angle_tolerance_deg: 1.0, + } + } +} + +impl AutoConstrainSettings { + pub fn sanitize(&mut self) { + let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len()); + for kind in self + .priority + .iter() + .copied() + .chain(AutoConstraintKind::ALL) + { + if !priority.contains(&kind) { + priority.push(kind); + } + } + self.priority = priority; + self.enabled + .retain(|kind| AutoConstraintKind::ALL.contains(kind)); + self.enabled.sort_by_key(|kind| { + self.priority + .iter() + .position(|candidate| candidate == kind) + .unwrap_or(usize::MAX) + }); + self.enabled.dedup(); + if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 { + self.distance_tolerance = 0.05; + } + if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 { + self.angle_tolerance_deg = 1.0; + } + } +} + /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { @@ -289,6 +386,19 @@ pub struct UserSettings { /// canvas detail on a dense sketch. #[serde(default = "default_show_constraint_values")] pub show_constraint_values: bool, + /// Inference types, priority, intersection rules, and tolerances used by + /// the Auto Constrain command. + #[serde(default)] + pub auto_constrain: AutoConstrainSettings, + /// Keep existing geometry size while solving after a constraint edit. + #[serde(default = "default_constraint_solve_mode")] + pub constraint_solve_mode: bool, + /// Apply eligible geometric constraints while creating geometry. + #[serde(default)] + pub constraint_infer: bool, + /// Constraint bar display bit mask: 1 after applying, 2 on selection. + #[serde(default = "default_constraint_bar_display")] + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -359,6 +469,14 @@ fn default_show_constraint_values() -> bool { true } +fn default_constraint_solve_mode() -> bool { + true +} + +fn default_constraint_bar_display() -> i16 { + 3 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -415,6 +533,10 @@ impl Default for UserSettings { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain: AutoConstrainSettings::default(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4dd845b72..fd58e70d5 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3247,6 +3247,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { + let driven_marker = match field { + "start_x" | "start_y" | "start_z" => Some(0), + "end_x" | "end_y" | "end_z" => Some(1), + "center_x" | "center_y" | "center_z" => Some(-3), + _ => None, + }; + let retained_originals: Vec<_> = if self.constraint_solve_mode + && driven_marker.is_some() + { + handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect() + } else { + Vec::new() + }; let evaluates_expression = self.tabs[i] .properties .sections @@ -3563,7 +3586,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - self.invalidate_property_targets(i, &handles); + let driven_refs: Vec<_> = driven_marker + .into_iter() + .flat_map(|marker| { + handles.iter().copied().map(move |handle| { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + }) + }) + .collect(); + self.invalidate_property_targets_with_originals( + i, + &handles, + &driven_refs, + &retained_originals, + ); self.tabs[i].dirty = true; self.refresh_properties(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ac9b254cf..1d7301d63 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -429,6 +429,10 @@ impl OpenCADStudio { backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, show_constraint_values: self.show_constraint_values, + auto_constrain: self.auto_constrain_settings.clone(), + constraint_solve_mode: self.constraint_solve_mode, + constraint_infer: self.constraint_infer, + constraint_bar_display: self.constraint_bar_display, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -510,6 +514,15 @@ impl OpenCADStudio { self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.show_constraint_values = s.show_constraint_values; + self.auto_constrain_settings = s.auto_constrain.clone(); + self.auto_constrain_settings.sanitize(); + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.constraint_solve_mode = s.constraint_solve_mode; + self.constraint_infer = s.constraint_infer; + self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4d3919596..6354847fb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4289,6 +4289,112 @@ impl OpenCADStudio { self.drafting_settings_close_confirm = false; Task::none() } + Message::AutoConstrainSelectRow(index) => { + if index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_selected_row = index; + } + Task::none() + } + Message::AutoConstrainToggleKind(kind) => { + if let Some(index) = self + .auto_constrain_settings + .enabled + .iter() + .position(|candidate| *candidate == kind) + { + self.auto_constrain_settings.enabled.remove(index); + } else { + self.auto_constrain_settings.enabled.push(kind); + } + Task::none() + } + Message::AutoConstrainMoveUp => { + let index = self.auto_constrain_selected_row; + if index > 0 && index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index - 1); + self.auto_constrain_selected_row -= 1; + } + Task::none() + } + Message::AutoConstrainMoveDown => { + let index = self.auto_constrain_selected_row; + if index + 1 < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index + 1); + self.auto_constrain_selected_row += 1; + } + Task::none() + } + Message::AutoConstrainSelectAll => { + self.auto_constrain_settings.enabled = + super::settings::AutoConstraintKind::ALL.to_vec(); + Task::none() + } + Message::AutoConstrainClearAll => { + self.auto_constrain_settings.enabled.clear(); + Task::none() + } + Message::AutoConstrainReset => { + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + Task::none() + } + Message::AutoConstrainToggleTangentPoint => { + self.auto_constrain_settings.tangent_must_share_point = + !self.auto_constrain_settings.tangent_must_share_point; + Task::none() + } + Message::AutoConstrainTogglePerpendicularIntersection => { + self.auto_constrain_settings.perpendicular_must_intersect = + !self.auto_constrain_settings.perpendicular_must_intersect; + Task::none() + } + Message::AutoConstrainDistanceChanged(value) => { + self.auto_constrain_distance_input = value; + Task::none() + } + Message::AutoConstrainAngleChanged(value) => { + self.auto_constrain_angle_input = value; + Task::none() + } + action @ (Message::AutoConstrainApply | Message::AutoConstrainOk) => { + let distance = self.auto_constrain_distance_input.trim().parse::(); + let angle = self.auto_constrain_angle_input.trim().parse::(); + match (distance, angle) { + (Ok(distance), Ok(angle)) + if distance.is_finite() + && distance >= 0.0 + && angle.is_finite() + && angle >= 0.0 => + { + self.auto_constrain_settings.distance_tolerance = distance; + self.auto_constrain_settings.angle_tolerance_deg = angle; + self.auto_constrain_settings.sanitize(); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); + self.persist_settings_if_changed(); + if matches!(action, Message::AutoConstrainOk) { + self.close_active_modal(); + } + } + _ => self.command_line.push_error( + crate::t!("Distance and angle tolerances must be non-negative numbers.") + .as_ref(), + ), + } + Task::none() + } + Message::AutoConstrainCancel => { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + self.close_active_modal(); + Task::none() + } // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { @@ -6847,6 +6953,11 @@ impl OpenCADStudio { return Task::none(); } self.drafting_settings_close_confirm = false; + if self.active_modal == Some(super::ModalKind::AutoConstrainSettings) { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + } let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ae3372607..11d5bf180 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -933,6 +933,13 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + let constraint_hover = self + .constraint_glyph_under(i, p) + .map(|(_, handles)| handles) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_constraint_hover_highlights(&constraint_hover); // Modifier-driven selection must be known before cursor_plane/axis and // drafting constraints are read, not merely before the final callback. if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -1737,11 +1744,31 @@ impl OpenCADStudio { ); } } + let driven_refs: Vec<_> = grip + .targets + .iter() + .filter_map(|target| { + let entity = self.tabs[i] + .scene + .document + .get_entity(target.handle)?; + crate::scene::parametric_constraints::driven_ref_for_grip( + entity, + target.handle, + target.grip_id, + ) + }) + .collect(); // Re-solve constrained neighbors on each drag frame and include // their original state in the gesture's undo record. let solved_by_constraints = self.tabs[i] .scene - .solve_parametric_constraints_preview(&edited_handles); + .solve_parametric_constraints_preview( + &edited_handles, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + &self.grip_originals, + ); for (handle, _) in &solved_by_constraints { let handle = *handle; if !self.grip_preview_handles.contains(&handle) { @@ -2637,6 +2664,7 @@ impl OpenCADStudio { // viewport so it doesn't stick while the mouse is over the // ribbon / panels. self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_constraint_hover_highlights(&[]); // Don't touch `context_menu` here. ViewportExit also fires // when an upper overlay (the right-click menu panel) takes // the cursor, so clearing the menu state on every exit @@ -2949,6 +2977,94 @@ impl OpenCADStudio { Task::none() } + fn constraint_glyph_under( + &self, + i: usize, + cursor: iced::Point, + ) -> Option<( + crate::scene::parametric_constraints::ConstraintKind, + Vec, + )> { + if self.tabs[i].scene.current_layout != "Model" { + return None; + } + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let scope = self.tabs[i].current_parametric_scope(); + let set = self.tabs[i].scene.parametric_constraint_set(scope)?; + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), + }; + let (view_rot, eye) = if let Some((camera, _)) = &edit_frame { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = self.tabs[i].scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + let mut ids = Vec::new(); + let mut glyphs = Vec::new(); + for constraint in set.constraints.iter().filter(|constraint| { + let selected = constraint.refs.iter().any(|reference| { + self.tabs[i].scene.selected.contains(&reference.entity) + }); + constraint.enabled + && self.tabs[i].scene.should_display_parametric_constraint( + scope, + constraint.id, + selected, + self.constraint_bar_display, + ) + }) { + let Some((anchor, outward)) = + crate::scene::parametric_constraints::glyph_placement( + &self.tabs[i].scene.document, + constraint, + ) else { + continue; + }; + let Some(screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let Some(outward_screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + let label = if self.show_constraint_values { + crate::scene::parametric_constraints::glyph_label(constraint) + } else { + constraint.kind.glyph_symbol().to_string() + }; + let conflict = set + .conflicts + .iter() + .any(|(id, _)| *id == constraint.id); + ids.push(constraint.id); + glyphs.push((point, direction.to_array(), label, conflict)); + } + let index = crate::ui::overlay::constraint_glyph_hit(&glyphs, cursor)?; + let constraint = set.get(ids[index])?; + let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + Some((constraint.kind, handles)) + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -3004,6 +3120,23 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + if self.tabs[i].active_cmd.is_none() { + if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entities(&handles); + self.refresh_selected_grips(); + self.refresh_properties(); + self.command_line + .push_info(&format!("{kind:?} constraint selected.")); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + return Task::none(); + } + } + // 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 diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bd48e6260..d4c133128 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -835,7 +835,17 @@ bg={bg_ms:.1}ms n={view_count}" set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id)) + .filter(|c| { + let selected = c.refs.iter().any(|reference| { + tab.scene.selected.contains(&reference.entity) + }); + tab.scene.should_display_parametric_constraint( + scope, + c.id, + selected, + self.constraint_bar_display, + ) + }) .filter_map(|c| { let (anchor, outward) = crate::scene::parametric_constraints::glyph_placement( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 218fe3e4d..2291d8654 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -25,6 +25,7 @@ impl OpenCADStudio { Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), + Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::Plot) => crate::tr!("modal", "plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -307,6 +308,20 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::AutoConstrainSettings => sized_flow( + ex, + 620, + 610, + |flow| { + crate::ui::window::auto_constrain_settings::view_window( + &self.auto_constrain_settings, + self.auto_constrain_selected_row, + &self.auto_constrain_distance_input, + &self.auto_constrain_angle_input, + flow, + ) + }, + ), super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, diff --git a/src/command.rs b/src/command.rs index b4e9da1df..bdc482e3f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1466,6 +1466,8 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Opens the Auto Constrain settings dialog from the selection prompt. + OpenAutoConstrainSettings, /// Adds a Coincident constraint between the sub-entity points nearest /// the two picked positions. The host resolves the raw points because /// `CadCommand` has no document access. diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 8116fbc07..d695ce6d7 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -34,6 +34,8 @@ pub struct SelectObjectsCommand { /// Whether the polygon being picked takes what it merely touches. Ignored /// by a fence, which has no inside to speak of. pick_crossing: bool, + /// Add the command-specific Settings keyword used by Auto Constrain. + auto_constrain_settings: bool, } impl SelectObjectsCommand { @@ -47,6 +49,7 @@ impl SelectObjectsCommand { show_options: true, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -61,6 +64,7 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -75,8 +79,15 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } + + pub fn auto_constrain(pending_cmd: &str) -> Self { + let mut command = Self::new(pending_cmd); + command.auto_constrain_settings = true; + command + } } impl CadCommand for SelectObjectsCommand { @@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand { .into_owned(); } if self.commit_on_enter && !self.handles.is_empty() { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):", + cmd = self.prompt_cmd, + count = self.handles.len() + ) + .into_owned(); + } t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", cmd = self.prompt_cmd, @@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand { ) .into_owned() } else { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings]:", + cmd = self.prompt_cmd + ) + .into_owned(); + } t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand { // the on-screen keyboard-less case needs. fn options(&self) -> Vec { if self.commit_on_enter && self.show_options { - vec![ + if self.auto_constrain_settings { + return vec![CmdOption::new(t!("Settings").as_ref(), "S")]; + } + let options = vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), CmdOption::new(t!("Fence").as_ref(), "F"), @@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand { CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Last").as_ref(), "L"), - ] + ]; + options } else { Vec::new() } @@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand { return None; } match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if self.auto_constrain_settings => { + Some(CmdResult::OpenAutoConstrainSettings) + } "F" | "FENCE" => { self.pick = Some(FencePick::fence()); Some(CmdResult::NeedPoint) diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..9539651ee 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -128,7 +128,7 @@ impl CadModule for ParametricModule { inventory::submit!(crate::command::CommandRegistration { names: &[ - "AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", + "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "DCCONVERT", "DELCONSTRAINT", ] diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 928cc2844..b8296048c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1676,6 +1676,8 @@ pub struct Scene { /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, + /// All entities related to the constraint indicator currently under the cursor. + constraint_hover_highlights: HashSet, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -1956,6 +1958,11 @@ pub struct Scene { parametric_constraints::ParametricScope, parametric_constraints::ConstraintId, )>, + /// Constraint glyphs explicitly shown through the constraint-bar commands. + shown_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, + )>, /// Document-wide named-parameter and expression table decoded from /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, @@ -2199,6 +2206,7 @@ impl Scene { command_preview_hidden: HashSet::default(), refedit_keep: None, hover_highlight: None, + constraint_hover_highlights: HashSet::default(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -2273,6 +2281,7 @@ impl Scene { associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), hidden_parametric_constraints: HashSet::default(), + shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), @@ -2832,6 +2841,33 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_policy(changes, &[], false); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { + self.bump_entities_with_parametric_policy(changes, driven_refs, false); + } + + pub fn bump_entities_with_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_parametric_originals(changes, driven_refs, retain_size, &[]); + } + + pub fn bump_entities_with_parametric_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2865,7 +2901,12 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints(&changes) { + for change in self.refresh_parametric_constraints_with_originals( + &changes, + driven_refs, + retain_size, + retained_originals, + ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } @@ -5069,9 +5110,20 @@ impl Scene { /// remains the picked member for hit-test/UI bookkeeping; rendering expands /// it to the same selectable group that a click would select. pub fn hover_highlight_handles(&self) -> HashSet { - self.hover_highlight + let mut handles = self + .hover_highlight .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) - .unwrap_or_default() + .unwrap_or_default(); + handles.extend(self.constraint_hover_highlights.iter().copied()); + handles + } + + pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { + let desired: HashSet<_> = handles.iter().copied().collect(); + if desired != self.constraint_hover_highlights { + self.constraint_hover_highlights = desired; + self.bump_selection(); + } } /// Keep the current selection visible and temporarily filter every other diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index fe55c5dc4..c8866ead4 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,6 +83,45 @@ impl ParametricRef { } } +/// Point reference driven by a native entity grip. Segment midpoint and +/// scalar radius grips deliberately return None because they do not identify +/// one stored point parameter. +pub(crate) fn driven_ref_for_grip( + entity: &acadrust::EntityType, + handle: Handle, + grip_id: usize, +) -> Option { + match entity { + acadrust::EntityType::Line(_) if grip_id <= 1 => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Arc(_) => match grip_id { + 0 => Some(ParametricRef::center(handle)), + 1 => Some(ParametricRef::point(handle, 0)), + 3 => Some(ParametricRef::point(handle, 1)), + _ => None, + }, + acadrust::EntityType::Circle(_) if grip_id == 0 => { + Some(ParametricRef::center(handle)) + } + acadrust::EntityType::Point(_) + | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + if grip_id == 0 => + { + Some(ParametricRef::point(handle, 0)) + } + _ => None, + } +} + /// The friendly, user-facing constraint types — the "what button did they /// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps /// onto (that mapping is `constraint_map`, a later stage; see the design @@ -576,6 +615,18 @@ impl super::Scene { !self.hidden_parametric_constraints.contains(&(scope, id)) } + pub fn should_display_parametric_constraint( + &self, + scope: ParametricScope, + id: ConstraintId, + related_entity_selected: bool, + display_mode: i16, + ) -> bool { + self.shown_parametric_constraints.contains(&(scope, id)) + || (display_mode & 1 != 0 && self.is_parametric_constraint_visible(scope, id)) + || (display_mode & 2 != 0 && related_entity_selected) + } + pub fn set_parametric_constraint_visibility( &mut self, scope: ParametricScope, @@ -601,8 +652,10 @@ impl super::Scene { for id in &ids { if visible { self.hidden_parametric_constraints.remove(&(scope, *id)); + self.shown_parametric_constraints.insert((scope, *id)); } else { self.hidden_parametric_constraints.insert((scope, *id)); + self.shown_parametric_constraints.remove(&(scope, *id)); } } ids.len() @@ -613,40 +666,155 @@ impl super::Scene { &self, scope: ParametricScope, handles: &[Handle], + settings: &crate::app::settings::AutoConstrainSettings, ) -> Vec<(ConstraintKind, Vec)> { use cadkernel::geom2d::{ - infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - ParametricPrimitive, Tolerance, + infer_constraints_with_settings, Arc, Circle, ConstraintEndpoint, InferenceKind, + InferenceSettings, InferredConstraint, Line, ParametricPrimitive, }; + struct Source { + handle: Handle, + primitive: ParametricPrimitive, + whole: ParametricRef, + endpoints: [ParametricRef; 2], + } let mut sources = Vec::new(); for handle in handles { - let primitive = match self.document.get_entity(*handle) { - Some(acadrust::EntityType::Line(line)) => ParametricPrimitive::Line(Line { - start: [line.start.x, line.start.y], - end: [line.end.x, line.end.y], + match self.document.get_entity(*handle) { + Some(acadrust::EntityType::Line(line)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), - Some(acadrust::EntityType::Circle(circle)) => ParametricPrimitive::Circle(Circle { - centre: [circle.center.x, circle.center.y], - radius: circle.radius, + Some(acadrust::EntityType::Circle(circle)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::center(*handle), + ParametricRef::center(*handle), + ], }), - Some(acadrust::EntityType::Arc(arc)) => ParametricPrimitive::Arc(Arc { - centre: [arc.center.x, arc.center.y], - radius: arc.radius, - start_angle: arc.start_angle, - end_angle: arc.end_angle, + Some(acadrust::EntityType::Arc(arc)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), + Some(acadrust::EntityType::LwPolyline(polyline)) => { + let Some(world) = crate::entities::curve::lwpolyline_world_xy(polyline) else { + continue; + }; + let count = world.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !world.is_closed { + break; + } + if world.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = world.vertices[index].location; + let b = world.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } + Some(acadrust::EntityType::Polyline2D(polyline)) => { + let count = polyline.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !polyline.is_closed() { + break; + } + if polyline.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } _ => continue, - }; - sources.push((*handle, primitive)); + } } - let primitives: Vec<_> = sources.iter().map(|(_, primitive)| *primitive).collect(); + let primitives: Vec<_> = sources.iter().map(|source| source.primitive).collect(); let marker = |endpoint| match endpoint { - ConstraintEndpoint::Start => 0, - ConstraintEndpoint::End => 1, + ConstraintEndpoint::Start => 0usize, + ConstraintEndpoint::End => 1usize, + }; + let kind = |kind| match kind { + crate::app::settings::AutoConstraintKind::Coincident => InferenceKind::Coincident, + crate::app::settings::AutoConstraintKind::Collinear => InferenceKind::Collinear, + crate::app::settings::AutoConstraintKind::Parallel => InferenceKind::Parallel, + crate::app::settings::AutoConstraintKind::Perpendicular => { + InferenceKind::Perpendicular + } + crate::app::settings::AutoConstraintKind::Tangent => InferenceKind::Tangent, + crate::app::settings::AutoConstraintKind::Concentric => InferenceKind::Concentric, + crate::app::settings::AutoConstraintKind::Horizontal => InferenceKind::Horizontal, + crate::app::settings::AutoConstraintKind::Vertical => InferenceKind::Vertical, + crate::app::settings::AutoConstraintKind::Equal => InferenceKind::Equal, + }; + let inference_settings = InferenceSettings { + priority: settings + .priority + .iter() + .copied() + .filter(|candidate| settings.enabled.contains(candidate)) + .map(kind) + .collect(), + distance_tolerance: settings.distance_tolerance, + angle_tolerance_radians: settings.angle_tolerance_deg.to_radians(), + tangent_must_share_point: settings.tangent_must_share_point, + perpendicular_must_intersect: settings.perpendicular_must_intersect, }; let mut mapped: Vec<_> = - infer_constraints(&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians()) + infer_constraints_with_settings(&primitives, &inference_settings) .into_iter() + .filter(|relation| { + !matches!(relation, InferredConstraint::Coincident { first, second, .. } + if sources[*first].handle == sources[*second].handle) + }) .map(|relation| match relation { InferredConstraint::Coincident { first, @@ -656,53 +824,57 @@ impl super::Scene { } => ( ConstraintKind::Coincident, vec![ - ParametricRef::point(sources[first].0, marker(first_endpoint)), - ParametricRef::point(sources[second].0, marker(second_endpoint)), + sources[first].endpoints[marker(first_endpoint)], + sources[second].endpoints[marker(second_endpoint)], ], ), InferredConstraint::Collinear { first, second } => ( ConstraintKind::Colinear, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Concentric { first, second } => ( ConstraintKind::Concentric, vec![ - ParametricRef::center(sources[first].0), - ParametricRef::center(sources[second].0), + ParametricRef::center(sources[first].handle), + ParametricRef::center(sources[second].handle), ], ), InferredConstraint::Parallel { first, second } => ( ConstraintKind::Parallel, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Perpendicular { first, second } => ( ConstraintKind::Perpendicular, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Horizontal { entity } => ( ConstraintKind::Horizontal, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Vertical { entity } => ( ConstraintKind::Vertical, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Tangent { first, second } => ( ConstraintKind::Tangent, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), + InferredConstraint::Equal { first, second } => ( + ConstraintKind::Equal, + vec![sources[first].whole, sources[second].whole], + ), }) .collect(); if let Some(existing) = self.parametric_constraint_set(scope) { @@ -1174,7 +1346,11 @@ mod tests { ); let inferred = - scene.inferred_parametric_constraints(ParametricScope::ModelSpace, &[first, second]); + scene.inferred_parametric_constraints( + ParametricScope::ModelSpace, + &[first, second], + &crate::app::settings::AutoConstrainSettings::default(), + ); assert!(!inferred.iter().any(|(kind, refs)| { *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 20e9581bb..9057a3410 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1549,6 +1549,44 @@ type SolveResult = ( )>, ); +fn retained_line_length(entity: &EntityType, segment: Option) -> Option { + match (entity, segment) { + (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::LwPolyline(polyline), Some(index)) => { + let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; + let a = world.vertices.get(index)?.location; + let b = if let Some(vertex) = world.vertices.get(index + 1) { + vertex.location + } else if world.is_closed { + world.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + (EntityType::Polyline2D(polyline), Some(index)) => { + let a = polyline.vertices.get(index)?.location; + let b = if let Some(vertex) = polyline.vertices.get(index + 1) { + vertex.location + } else if polyline.is_closed() { + polyline.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + _ => None, + } +} + +fn retained_radius(entity: &EntityType) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.radius), + EntityType::Arc(arc) => Some(arc.radius), + _ => None, + } +} + /// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document /// geometry, solves it, and returns the resulting entity states for every /// handle whose registered coordinates actually moved beyond floating-point @@ -1564,6 +1602,9 @@ fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + retain_size: bool, + retained_before: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1597,6 +1638,81 @@ fn solve_scope( return None; } + // Preserve current line lengths and radii for this solve when + // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the + // solution without becoming persistent dimensional constraints. + if retain_size { + for (handle, geom) in &cache { + match geom { + EntityGeom::Line(line) => { + let current_length = { + let store = sys.store(); + let dx = store.get(line.p2.x) - store.get(line.p1.x); + let dy = store.get(line.p2.y) - store.get(line.p1.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, None)) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, target))); + } + EntityGeom::Polyline { + points, + straight, + closed, + } => { + for index in 0..straight.len() { + if !straight[index] { + continue; + } + let Some(a) = points.get(index).copied() else { + continue; + }; + let b = if let Some(point) = points.get(index + 1).copied() { + Some(point) + } else if *closed { + points.first().copied() + } else { + None + }; + let Some(b) = b else { continue }; + let current_length = { + let store = sys.store(); + let dx = store.get(b.x) - store.get(a.x); + let dy = store.get(b.y) - store.get(a.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, Some(index))) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + } + } + EntityGeom::Circle(circle) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(circle.rad, target, 1.0))); + } + EntityGeom::Arc(arc) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(arc.circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); + } + _ => {} + } + } + } + // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, // unconditionally — the kernel adds these the moment an arc @@ -1633,6 +1749,33 @@ fn solve_scope( ))); } + // A point explicitly edited by a grip, STRETCH, or Properties is the + // driver's target for this solve. Pin its current coordinates for this + // solve only so constrained neighbours respond while the chosen point + // stays at the exact cursor or typed coordinate. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|r| r.entity == reference.entity)) + { + continue; + } + let Some(point) = + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + else { + continue; + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + let target_x = sys.add_param(x, true); + let target_y = sys.add_param(y, true); + sys.add_constraint(Rc::new(Equal::new(point.x, target_x, 1.0))); + sys.add_constraint(Rc::new(Equal::new(point.y, target_y, 1.0))); + } + // Ellipse rules (same motivation as arc rules, different mechanism): // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an // offset from `center`. If some constraint (e.g. Concentric) pulls only @@ -2151,9 +2294,43 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. + #[allow(dead_code)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, &[], false) + } + + #[allow(dead_code)] + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, driven_refs, false) + } + + pub(crate) fn refresh_parametric_constraints_with_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_originals( + changes, + driven_refs, + retain_size, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2185,6 +2362,25 @@ impl Scene { } } + let retained_before: HashMap> = if retain_size { + let mut retained: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + retained.insert(*handle, std::sync::Arc::new(entity.clone())); + } + retained + } else { + HashMap::new() + }; let mut result = Vec::new(); for i in 0..self.parametric_constraints.len() { let touched = changes.iter().any(|(handle, _)| { @@ -2200,6 +2396,9 @@ impl Scene { &self.document, &self.named_parameters, &self.parametric_constraints[i], + driven_refs, + retain_size, + &retained_before, ) else { continue; }; @@ -2224,10 +2423,17 @@ impl Scene { pub(crate) fn solve_parametric_constraints_preview( &self, touched: &[Handle], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, EntityType)> { if self.parametric_constraints.is_empty() || touched.is_empty() { return Vec::new(); } + let retained_before: HashMap> = retained_originals + .iter() + .map(|(handle, entity)| (*handle, std::sync::Arc::new(entity.clone()))) + .collect(); let mut result = Vec::new(); for set in &self.parametric_constraints { let is_touched = touched @@ -2236,8 +2442,14 @@ impl Scene { if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) + let Some((solved, _dof, _conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + set, + driven_refs, + retain_size, + &retained_before, + ) else { continue; }; @@ -2515,7 +2727,7 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_parametric_constraints_preview(&[a]); + let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); assert_eq!( scene.document.get_entity(b), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..a52a57543 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1001,6 +1001,7 @@ impl Scene { self.hover_highlight = None; hover_changed = true; } + hover_changed |= self.constraint_hover_highlights.remove(&h); self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index efde7319e..601a59482 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3435,7 +3435,10 @@ impl Scene { &self, inst: &ViewportInstance, ) -> Arc> { - if self.selected.is_empty() && self.hover_highlight.is_none() { + if self.selected.is_empty() + && self.hover_highlight.is_none() + && self.constraint_hover_highlights.is_empty() + { return Arc::new(Vec::new()); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4cd3418d2..3b6b9468c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -94,6 +94,27 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option { + let offsets = constraint_glyph_offsets(glyphs); + glyphs + .iter() + .zip(offsets) + .enumerate() + .rev() + .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| { + let (top_left, size) = + constraint_glyph_box(*anchor, *outward, label, tangent_offset); + (cursor.x >= top_left.x + && cursor.x <= top_left.x + size.width + && cursor.y >= top_left.y + && cursor.y <= top_left.y + size.height) + .then_some(index) + }) +} + /// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 60 px cursor at the default value and the full-viewport result at 100. pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { @@ -989,6 +1010,11 @@ impl canvas::Program for SelectionCanvas { } } } + if let Some(pos) = cursor.position_in(bounds) { + if constraint_glyph_hit(&self.constraint_glyphs, pos).is_some() { + return mouse::Interaction::Pointer; + } + } // The resize cursor over a divider is supplied by the input pane_grid // layered above this overlay; `draw` only suppresses the CAD crosshair // there (see `divider_under`). @@ -1703,7 +1729,6 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Constraint glyphs are visual-only and have no hit testing. if !self.constraint_glyphs.is_empty() { let offsets = constraint_glyph_offsets(&self.constraint_glyphs); let normal_bg = theme.palette().primary.base.color; diff --git a/src/ui/window/auto_constrain_settings.rs b/src/ui/window/auto_constrain_settings.rs new file mode 100644 index 000000000..2a5afa621 --- /dev/null +++ b/src/ui/window/auto_constrain_settings.rs @@ -0,0 +1,143 @@ +use crate::app::settings::AutoConstrainSettings; +use crate::app::Message; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + settings: &'a AutoConstrainSettings, + selected_row: usize, + distance_input: &'a str, + angle_input: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let heading = row![ + text(crate::t!("Priority")).width(70), + text(crate::t!("Constraint Type")).width(Fill), + text(crate::t!("Apply")).width(60), + ] + .spacing(8); + + let mut list = column![heading].spacing(3); + for (index, kind) in settings.priority.iter().copied().enumerate() { + let enabled = settings.enabled.contains(&kind); + let label = row![ + text(format!("{}", index + 1)).width(70), + text(crate::t!(kind.label())).width(Fill), + checkbox(enabled) + .on_toggle(move |_| Message::AutoConstrainToggleKind(kind)) + .width(60), + ] + .spacing(8) + .align_y(iced::Center); + list = list.push( + button(label) + .on_press(Message::AutoConstrainSelectRow(index)) + .style(if selected_row == index { + button::primary + } else { + button::text + }) + .width(Fill) + .padding([5, 8]), + ); + } + + let reorder = column![ + button(text(crate::t!("Move Up"))) + .on_press_maybe((selected_row > 0).then_some(Message::AutoConstrainMoveUp)) + .width(Fill), + button(text(crate::t!("Move Down"))) + .on_press_maybe( + (selected_row + 1 < settings.priority.len()) + .then_some(Message::AutoConstrainMoveDown) + ) + .width(Fill), + Space::new().height(8), + button(text(crate::t!("Select All"))) + .on_press(Message::AutoConstrainSelectAll) + .width(Fill), + button(text(crate::t!("Clear All"))) + .on_press(Message::AutoConstrainClearAll) + .width(Fill), + button(text(crate::t!("Reset"))) + .on_press(Message::AutoConstrainReset) + .width(Fill), + ] + .spacing(6) + .width(125); + + let types = row![ + container(scrollable(list).height(260)) + .padding(6) + .width(Fill), + reorder + ] + .spacing(12); + + let conditions = column![ + checkbox(settings.tangent_must_share_point) + .label(crate::t!("Tangent objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainToggleTangentPoint), + checkbox(settings.perpendicular_must_intersect) + .label(crate::t!("Perpendicular objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainTogglePerpendicularIntersection), + ] + .spacing(8); + + let tolerances = column![ + text(crate::t!("Tolerances")).size(14), + row![ + text(crate::t!("Distance")).width(130), + text_input("0.05", distance_input) + .on_input(Message::AutoConstrainDistanceChanged) + .width(150), + ] + .align_y(iced::Center), + row![ + text(crate::t!("Angle")).width(130), + text_input("1", angle_input) + .on_input(Message::AutoConstrainAngleChanged) + .width(150), + text("°"), + ] + .align_y(iced::Center), + ] + .spacing(7); + + let actions = row![ + Space::new().width(Fill), + button(text(crate::t!("OK"))) + .on_press(Message::AutoConstrainOk) + .style(button::primary) + .padding([6, 18]), + button(text(crate::t!("Apply"))) + .on_press(Message::AutoConstrainApply) + .style(button::secondary) + .padding([6, 18]), + button(text(crate::t!("Cancel"))) + .on_press(Message::AutoConstrainCancel) + .style(button::secondary) + .padding([6, 18]), + ] + .spacing(8) + .align_y(iced::Center); + + container( + column![ + text(crate::t!("Auto Constrain")).size(16), + types, + conditions, + tolerances, + Space::new().height(Fill), + actions, + ] + .spacing(14) + .width(sizing.width) + .height(sizing.height), + ) + .padding([14, 16]) + .width(sizing.width) + .height(sizing.height) + .style(container::rounded_box) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 32b0f5d3a..09b3b2314 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -5,6 +5,7 @@ pub mod layer_state_manager; pub mod drawing_units; pub mod geometric_tolerance; pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; pub mod plot; pub mod print_all;