From ba2b3712696f91e17670ca9aaebd82be5e2dae0e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:00:26 +0300 Subject: [PATCH] Complete Coincident command behavior --- src/app/command_driver.rs | 220 ++++++++++++--- src/app/commands/draw.rs | 9 +- src/app/properties.rs | 82 +----- src/command.rs | 30 +- src/entities/line.rs | 16 ++ src/modules/parametric/coincident.rs | 275 +++++++++++++++++-- src/modules/parametric/geom_constraint.rs | 87 ++++++ src/modules/parametric/mod.rs | 2 + src/scene/cache/properties.rs | 11 +- src/scene/mod.rs | 10 +- src/scene/parametric_constraints.rs | 309 ++++++++++++++++++++- src/scene/parametric_solve.rs | 320 +++++++++++++++++++--- src/scene/view/dispatch.rs | 19 ++ 13 files changed, 1183 insertions(+), 207 deletions(-) create mode 100644 src/modules/parametric/geom_constraint.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 5546cddf8..59a54b549 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2082,10 +2082,17 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; return Task::none(); } + if matches!(&transform, crate::command::EntityTransform::Translate(_)) { + let scope = self.tabs[i].current_parametric_scope(); + handles = self.tabs[i] + .scene + .parametric_connected_handles(scope, &handles); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + } let label = self.history_label_from_active_cmd(i, "MOVE"); - // A move/rotate/scale/mirror mutates only the selected entities - // (and their baked dimension sub-entities) through - // transform_entities — always delta-safe. + // A translation carries its complete connected constraint + // component so each member keeps its own dimensions. Other + // transforms retain their selected-object behavior. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); self.tabs[i].dirty = true; @@ -2955,44 +2962,165 @@ impl OpenCADStudio { } } CmdResult::AddCoincidentConstraint { - point_a, - point_b, + first, + second, + multiple, label, } => { let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved_a = crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(point_a), - None, - ); - let resolved_b = resolved_a.and_then(|ref_a| { - crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(point_b), - None, - ) - .filter(|ref_b| *ref_b != ref_a) - }); - match (resolved_a, resolved_b) { - (Some(ref_a), Some(ref_b)) => { - return self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::Coincident, - refs: vec![ref_a, ref_b], - driving_param: None, - label, - }); + let resolve = |pick: crate::command::CoincidentPick, + exclude: Option| { + if pick.whole_curve { + pick.handle.and_then(|handle| { + crate::scene::parametric_constraints::parametric_curve_ref_for_pick( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + }) + } else if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + exclude, + ) } + }; + let resolved_first = resolve(first, None); + let resolved_second = resolved_first + .and_then(|reference| resolve(second, Some(reference.entity))); + let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { + self.command_line.push_error( + "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", + ); + return Task::none(); + }; + let (kind, refs) = match (first.whole_curve, second.whole_curve) { + (false, false) if first_ref != second_ref => ( + crate::scene::parametric_constraints::ConstraintKind::Coincident, + vec![first_ref, second_ref], + ), + (true, false) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![second_ref, first_ref], + ), + (false, true) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![first_ref, second_ref], + ), _ => { - self.command_line.push_error( - "Coincident: pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", - ); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; + self.command_line + .push_error("Coincident: select one point and one curve, or two points."); + return Task::none(); + } + }; + if let Err(error) = self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(error); + return Task::none(); + } + let touched: Vec<_> = refs.iter().map(|reference| reference.entity).collect(); + let constraints_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, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + let changes: Vec<_> = touched + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i] + .scene + .bump_entities_with_parametric_driven(&changes, &[first_ref]); + self.tabs[i].dirty = true; + self.tabs[i].snap_result = None; + if !multiple { + self.tabs[i].active_cmd = None; + } + self.command_line.push_output("Coincident constraint applied."); + if multiple { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { + self.command_line.push_info(&prompt); + } + self.command_line.set_step_options( + self.tabs[i] + .active_cmd + .as_ref() + .map(|cmd| cmd.options()) + .unwrap_or_default(), + ); + } + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddAutoCoincidentConstraints { handles } => { + use crate::scene::parametric_constraints::ConstraintKind; + let scope = self.tabs[i].current_parametric_scope(); + let inferred = self.tabs[i] + .scene + .inferred_coincident_constraints(scope, &handles); + let count = inferred.len(); + if count > 0 { + let constraints_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, "Coincident auto constrain", handles.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + for refs in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Coincident, 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(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); } } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(format!("{} coincident constraint(s) applied.", count).as_str()); } CmdResult::AddPointOnEntityConstraint { point, @@ -8276,8 +8404,17 @@ mod parametric_constraint_undo_tests { let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end - point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start + first: crate::command::CoincidentPick { + handle: Some(a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: Some(b), + point: glam::DVec3::new(20.0, 0.0, 0.0), + whole_curve: false, + }, + multiple: false, label: "Coincident constraint", }); @@ -8305,8 +8442,17 @@ mod parametric_constraint_undo_tests { let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point - point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything + first: crate::command::CoincidentPick { + handle: Some(_a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: None, + point: glam::DVec3::new(500.0, 500.0, 0.0), + whole_curve: false, + }, + multiple: false, label: "Coincident constraint", }); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e45c8c1a4..05769fbcb 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1300,13 +1300,20 @@ impl OpenCADStudio { } } - "CCONSTRAINT" => { + "CCONSTRAINT" | "GCCOINCIDENT" => { use crate::modules::parametric::CoincidentConstraintCommand; let new_cmd = CoincidentConstraintCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "GEOMCONSTRAINT" => { + use crate::modules::parametric::GeomConstraintCommand; + let new_cmd = GeomConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); diff --git a/src/app/properties.rs b/src/app/properties.rs index f31741f8b..50c485ab9 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -882,12 +882,21 @@ impl OpenCADStudio { } if !compact_solid { - sections.extend(crate::entities::object_data::sections( + let mut object_sections = crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, handle, entity, - )); + ); + let scope = self.tabs[i].current_parametric_scope(); + let constrained = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| set.constraints_touching(handle).next().is_some()); + if constrained { + object_sections.retain(|section| section.title != "Associative Data"); + } + sections.extend(object_sections); } { @@ -2225,75 +2234,6 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - // Parametric-constraint list for this entity — - // the Properties-panel analogue of the viewport's - // constraint glyph pills: one row per constraint - // touching `handle`, clickable to select every entity - // it relates (PropConstraintLinkClick). Omitted - // entirely when the entity has none — an unconstrained - // entity (the common case) shouldn't gain empty - // bookkeeping. - { - let scope = self.tabs[i].current_parametric_scope(); - let scene = &self.tabs[i].scene; - if let Some(set) = scene.parametric_constraint_set(scope) { - let parameters = if set.local_parameters.is_empty() { - scene.named_parameters() - } else { - &set.local_parameters - }; - let props: Vec = set - .constraints_touching(handle) - .map(|c| { - let conflicting = - set.conflicts.iter().any(|(id, _)| *id == c.id); - let value_suffix = match &c.driving_param { - Some( - crate::scene::named_parameters::DrivingValue::Literal( - v, - ), - ) => { - format!(" = {v:.2}") - } - Some( - crate::scene::named_parameters::DrivingValue::Named( - name, - ), - ) => match parameters.resolve(name) { - Ok(v) => format!(" = {name} ({v:.2})"), - Err(_) => format!(" = {name} (?)"), - }, - None => String::new(), - }; - let label = format!( - "{} {:?}{}", - c.kind.glyph_symbol(), - c.kind, - value_suffix - ); - let mut handles: Vec = - c.refs.iter().map(|r| r.entity).collect(); - handles.sort_by_key(|h| h.value()); - handles.dedup(); - crate::scene::model::object::Property { - label, - field: "parametric_constraint", - value: crate::scene::model::object::PropValue::EntityLink { - id: c.id, - handles, - conflicting, - }, - } - }) - .collect(); - if !props.is_empty() { - sections.push(crate::scene::model::object::PropSection { - title: t!("Constraints").into_owned(), - props, - }); - } - } - } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] diff --git a/src/command.rs b/src/command.rs index b4e9da1df..b01e58558 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1237,6 +1237,17 @@ pub enum ExtrudeExtent { Path(Handle), } +/// One ordered selection used by the Coincident command. A click can name +/// either a constraint point on an entity or the entity's whole curve. Typed +/// coordinates have no entity handle and are resolved by the host after the +/// command returns. +#[derive(Clone, Copy, Debug)] +pub struct CoincidentPick { + pub handle: Option, + pub point: DVec3, + pub whole_curve: bool, +} + /// Construction options shared by SWEEP creation and its live preview. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SweepOptions { @@ -1466,14 +1477,21 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, - /// 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. + /// Adds an ordered Coincident relation. Point/point selections create a + /// Coincident constraint; point/curve selections create the corresponding + /// point-on-curve relation while retaining Coincident command semantics. AddCoincidentConstraint { - point_a: DVec3, - point_b: DVec3, + first: CoincidentPick, + second: CoincidentPick, + /// Keep the first curve active and accept another point. + multiple: bool, label: &'static str, }, + /// Apply only Coincident relations that already exist geometrically in + /// the selected set. + AddAutoCoincidentConstraints { + handles: Vec, + }, /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint /// (`crate::modules::parametric::point_on_entity`) between one /// picked point and `target`, a whole entity selected before the tool @@ -2681,6 +2699,8 @@ mod constraint_registry_tests { let names = all_registered_command_names(); for id in [ "CCONSTRAINT", + "GCCOINCIDENT", + "GEOMCONSTRAINT", "EDCONSTRAINT", "CPCONSTRAINT", "MPCONSTRAINT", diff --git a/src/entities/line.rs b/src/entities/line.rs index 0905194d8..0f4e9fdfb 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -214,6 +214,9 @@ fn properties(line: &Line) -> Vec { ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")), ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")), + edit(t!("Normal X").as_ref(), "normal_x", line.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z), ], }] } @@ -286,6 +289,19 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { "end_x" => line.end.x = v, "end_y" => line.end.y = v, "end_z" => line.end.z = v, + "normal_x" | "normal_y" | "normal_z" => { + let mut normal = glam::DVec3::new(line.normal.x, line.normal.y, line.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => unreachable!(), + } + if normal.is_finite() && normal.length_squared() > 1.0e-18 { + let normal = normal.normalize(); + line.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + } + } _ => {} } } diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index 356c6b556..cb4f3b7a6 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -1,20 +1,10 @@ -//! CCONSTRAINT — manual Coincident constraint input. -//! unlike the rest of this module's plain select-and-click constraints, -//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc -//! center), not the whole entity, so it needs two point picks instead of a -//! selection. -//! -//! A `CadCommand` has no document access (see `value.rs`'s module doc -//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this -//! command only accumulates the two raw points `on_point` receives and hands -//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves -//! each point to a real `ParametricRef` (`parametric_constraints::nearest_parametric_point`) -//! and adds the constraint, the same split `ReassociateCenterMark` already -//! uses for a similar reason. +//! Interactive Coincident input: ordered point/curve picks, repeated +//! point-to-curve placement, and Coincident-only automatic inference. +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; pub mod coincident_tool { @@ -31,45 +21,272 @@ pub mod coincident_tool { } } -#[derive(Default)] +#[derive(Clone)] +enum Step { + First, + FirstObject, + Second(CoincidentPick), + PointsOnCurve { + curve: CoincidentPick, + multiple: bool, + }, + AutoConstrain { + handles: Vec, + }, +} + pub struct CoincidentConstraintCommand { - first: Option, + step: Step, + picked_entity: Option, } impl CoincidentConstraintCommand { pub fn new() -> Self { - Self::default() + Self { + step: Step::First, + picked_entity: None, + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn curve_pick(handle: Handle, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle: Some(handle), + point, + whole_curve: true, + } + } + + fn picked_constraint_point(entity: &EntityType, point: DVec3) -> bool { + crate::scene::parametric_constraints::is_parametric_point_near( + entity, + acadrust::types::Vector3::new(point.x, point.y, point.z), + ) + } + + fn valid_curve(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Line(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline2D(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + ) } } impl CadCommand for CoincidentConstraintCommand { fn name(&self) -> &'static str { - "CCONSTRAINT" + "GCCOINCIDENT" } fn prompt(&self) -> String { - if self.first.is_none() { - "COINCIDENT Specify first point:".to_string() - } else { - "COINCIDENT Specify second point:".to_string() + match self.step.clone() { + Step::First => { + "COINCIDENT Select first point or [Object/Autoconstrain] :".to_string() + } + Step::FirstObject => "COINCIDENT Select object:".to_string(), + Step::Second(_) => "COINCIDENT Select second point or object:".to_string(), + Step::PointsOnCurve { multiple: false, .. } => { + "COINCIDENT Select point or [Multiple]:".to_string() + } + Step::PointsOnCurve { multiple: true, .. } => { + "COINCIDENT Select points to coincide with the first object (Enter = done):" + .to_string() + } + Step::AutoConstrain { handles } if handles.is_empty() => { + "COINCIDENT Select objects:".to_string() + } + Step::AutoConstrain { handles } => format!( + "COINCIDENT Select objects ({} selected, Enter = apply):", + handles.len() + ), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match self.first { - None => { - self.first = Some(pt); + match &self.step { + Step::First => { + self.step = Step::Second(Self::point_pick(None, pt)); CmdResult::NeedPoint } - Some(first) => CmdResult::AddCoincidentConstraint { - point_a: first, - point_b: pt, + Step::FirstObject => CmdResult::NeedPoint, + Step::Second(first) => CmdResult::AddCoincidentConstraint { + first: *first, + second: Self::point_pick(None, pt), + multiple: false, label: "Coincident constraint", }, + Step::PointsOnCurve { curve, multiple } => CmdResult::AddCoincidentConstraint { + first: *curve, + second: Self::point_pick(None, pt), + multiple: *multiple, + label: "Coincident constraint", + }, + Step::AutoConstrain { .. } => CmdResult::NeedPoint, } } + fn options(&self) -> Vec { + match self.step.clone() { + Step::First => vec![ + CmdOption::new("Object", "O"), + CmdOption::new("Autoconstrain", "A"), + ], + Step::PointsOnCurve { multiple: false, .. } => vec![ + CmdOption::new("Point", "P"), + CmdOption::new("Multiple", "M"), + ], + Step::PointsOnCurve { multiple: true, .. } => vec![CmdOption::enter("Done")], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + match (&mut self.step, keyword.as_str()) { + (Step::First, "O" | "OBJECT") => { + self.step = Step::FirstObject; + Some(CmdResult::NeedPoint) + } + (Step::First, "A" | "AUTOCONSTRAIN") => { + self.step = Step::AutoConstrain { handles: Vec::new() }; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { multiple, .. }, "M" | "MULTIPLE") => { + *multiple = true; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { .. }, "P" | "POINT") => Some(CmdResult::NeedPoint), + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let is_point = self + .picked_entity + .as_ref() + .is_some_and(|entity| Self::picked_constraint_point(entity, point)); + let is_curve = self + .picked_entity + .as_ref() + .is_some_and(Self::valid_curve); + self.picked_entity = None; + + match self.step.clone() { + Step::FirstObject if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::First if is_point => { + self.step = Step::Second(Self::point_pick(Some(handle), point)); + CmdResult::NeedPoint + } + Step::First if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::Second(first) if is_point => CmdResult::AddCoincidentConstraint { + first, + second: Self::point_pick(Some(handle), point), + multiple: false, + label: "Coincident constraint", + }, + Step::Second(first) if is_curve => CmdResult::AddCoincidentConstraint { + first, + second: Self::curve_pick(handle, point), + multiple: false, + label: "Coincident constraint", + }, + Step::PointsOnCurve { curve, multiple } if is_point => { + CmdResult::AddCoincidentConstraint { + first: curve, + second: Self::point_pick(Some(handle), point), + multiple, + label: "Coincident constraint", + } + } + _ => CmdResult::NeedPoint, + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if let Step::AutoConstrain { handles: selected } = &mut self.step { + *selected = handles; + } + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if matches!(&self.step, Step::First) { + self.step = Step::FirstObject; + return CmdResult::NeedPoint; + } + if let Step::AutoConstrain { handles } = &self.step { + if !handles.is_empty() { + return CmdResult::AddAutoCoincidentConstraints { + handles: handles.clone(), + }; + } + } CmdResult::Cancel } @@ -80,5 +297,5 @@ impl CadCommand for CoincidentConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["CCONSTRAINT"] + names: &["CCONSTRAINT", "GCCOINCIDENT"] }); diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs new file mode 100644 index 000000000..6bf27ddde --- /dev/null +++ b/src/modules/parametric/geom_constraint.rs @@ -0,0 +1,87 @@ +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +/// Command-line front end for the standard geometric-constraint family. +/// Each choice is handed to the existing focused command so ribbon and typed +/// entry use one implementation. +pub struct GeomConstraintCommand; + +impl GeomConstraintCommand { + pub fn new() -> Self { + Self + } + + fn dispatch(keyword: &str) -> Option<&'static str> { + Some(match keyword { + "H" | "HORIZONTAL" => "HCONSTRAINT", + "V" | "VERTICAL" => "VCONSTRAINT", + "P" | "PERPENDICULAR" => "QCONSTRAINT", + "PA" | "PARALLEL" => "PCONSTRAINT", + "T" | "TANGENT" => "TCONSTRAINT", + "SM" | "SMOOTH" => "SMOOTHCONSTRAINT", + "C" | "COINCIDENT" => "GCCOINCIDENT", + "CON" | "CONCENTRIC" => "NCONSTRAINT", + "COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT", + "SY" | "SYMMETRIC" => "SYCONSTRAINT", + "E" | "EQUAL" => "ECONSTRAINT", + "F" | "FIX" => "FXCONSTRAINT", + _ => return None, + }) + } +} + +impl CadCommand for GeomConstraintCommand { + fn name(&self) -> &'static str { + "GEOMCONSTRAINT" + } + + fn prompt(&self) -> String { + "GEOMCONSTRAINT Enter constraint type [Horizontal/Vertical/Perpendicular/Parallel/Tangent/Smooth/Coincident/Concentric/Collinear/Symmetric/Equal/Fix] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Horizontal", "H"), + CmdOption::new("Vertical", "V"), + CmdOption::new("Perpendicular", "P"), + CmdOption::new("Parallel", "PA"), + CmdOption::new("Tangent", "T"), + CmdOption::new("Smooth", "SM"), + CmdOption::new("Coincident", "C"), + CmdOption::new("Concentric", "CON"), + CmdOption::new("Collinear", "COL"), + CmdOption::new("Symmetric", "SY"), + CmdOption::new("Equal", "E"), + CmdOption::new("Fix", "F"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string())) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("GCCOINCIDENT".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["GEOMCONSTRAINT"] +}); diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..07dcde806 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -4,11 +4,13 @@ mod coincident; mod equal_distance; +mod geom_constraint; mod point_on_entity; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use geom_constraint::GeomConstraintCommand; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 4a08c1571..4490e24ea 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -32,7 +32,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); - let mut section = PropSection { + let section = PropSection { title: t!("General").into_owned(), props: vec![ Property { @@ -88,15 +88,6 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - // Thickness (DXF 39) is a General-group property, but only the entity - // types that carry an extrusion thickness expose it (line, circle, arc, - // polyline, text, 2D solid, …). Show it right after Hyperlink for those. - if let Some(t) = crate::scene::view::dispatch::entity_thickness(entity) { - section - .props - .push(crate::entities::common::edit_prop(t!("Thickness").as_ref(), "thickness", t)); - } - section } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 6269866ce..f6536d716 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2836,6 +2836,14 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_driven(changes, &[]); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2869,7 +2877,7 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints(&changes) { + for change in self.refresh_parametric_constraints_with_driven(&changes, driven_refs) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 387dbaac6..6cbd648a6 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -361,6 +361,7 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio return match entity { acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()), + acadrust::EntityType::Ellipse(ellipse) => Some(ellipse.center), _ => None, }; } @@ -392,6 +393,23 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } else { return None; }; + let bulge = match entity { + acadrust::EntityType::LwPolyline(polyline) => { + polyline.vertices.get(segment)?.bulge + } + acadrust::EntityType::Polyline2D(polyline) => { + polyline.vertices.get(segment)?.bulge + } + _ => 0.0, + }; + if let Some(arc) = cadkernel::geom2d::BulgeArc::from_bulge( + [a.x, a.y], + [b.x, b.y], + bulge, + ) { + let midpoint = arc.sample(0.5); + return Some(Vector3::new(midpoint[0], midpoint[1], (a.z + b.z) * 0.5)); + } return Some((a + b) * 0.5); } if marker < 0 { @@ -406,6 +424,97 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio /// already coincident for [`nearest_parametric_point`]'s purposes. const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; +/// Addressable constraint points for one entity, in the same marker space +/// used by persistent constraint references. +pub(crate) fn parametric_point_candidates( + entity: &acadrust::EntityType, +) -> Vec<(i32, Vector3)> { + let mut points: Vec<_> = super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + .map(|(marker, point)| (marker as i32, point)) + .collect(); + match entity { + acadrust::EntityType::Circle(circle) => points.push((-3, circle.center_wcs())), + acadrust::EntityType::Arc(arc) => points.push((-3, arc.center_wcs())), + acadrust::EntityType::Ellipse(ellipse) => points.push((-3, ellipse.center)), + _ => {} + } + + if matches!( + entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Spline(_) + | acadrust::EntityType::Ellipse(_) + ) { + if let Some(curve) = crate::entities::curve::entity_curve(entity) { + if !curve.is_closed() { + let point = curve.point_at(0.5); + points.push((-2, Vector3::new(point[0], point[1], point[2]))); + } + } + } + + let polyline_midpoints = |vertices: &[Vector3], bulges: &[f64], closed: bool| { + let mut result = Vec::new(); + for (index, bulge) in bulges.iter().copied().enumerate() { + let Some(a) = vertices.get(index).copied() else { + continue; + }; + let b = vertices + .get(index + 1) + .copied() + .or_else(|| closed.then(|| vertices.first().copied()).flatten()); + if let Some(b) = b { + let midpoint = cadkernel::geom2d::BulgeArc::from_bulge( + [a.x, a.y], + [b.x, b.y], + bulge, + ) + .map(|arc| { + let point = arc.sample(0.5); + Vector3::new(point[0], point[1], (a.z + b.z) * 0.5) + }) + .unwrap_or((a + b) * 0.5); + result.push(( + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, + midpoint, + )); + } + } + result + }; + match entity { + acadrust::EntityType::LwPolyline(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + let bulges: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| vertex.bulge) + .collect(); + points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed)); + } + acadrust::EntityType::Polyline2D(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + let bulges: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| vertex.bulge) + .collect(); + points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed())); + } + _ => {} + } + points +} + +pub(crate) fn is_parametric_point_near(entity: &acadrust::EntityType, point: Vector3) -> bool { + parametric_point_candidates(entity) + .into_iter() + .any(|(_, candidate)| (candidate - point).length_squared() <= COINCIDENT_EPSILON_SQ) +} + /// Finds the addressable entity point nearest a snapped world position. /// Returns `None` when no point in the scope is within the coincidence /// tolerance. @@ -431,21 +540,96 @@ pub(crate) fn nearest_parametric_point( if common.owner_handle != owner || Some(common.handle) == exclude { continue; } - for (marker, point) in super::dimension_assoc::source_points(candidate) - .into_iter() - .enumerate() - { - consider(common.handle, marker as i32, point); - } - match candidate { - acadrust::EntityType::Circle(c) => consider(common.handle, -3, c.center_wcs()), - acadrust::EntityType::Arc(a) => consider(common.handle, -3, a.center_wcs()), - _ => {} + for (marker, point) in parametric_point_candidates(candidate) { + consider(common.handle, marker, point); } } best.map(|(_, r)| r) } +/// Resolve a point pick within one explicitly selected entity. This keeps two +/// different endpoints at the same world coordinate distinguishable. +pub(crate) fn nearest_parametric_point_on_entity( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + parametric_point_candidates(entity) + .into_iter() + .filter_map(|(marker, point)| { + let distance = (point - world_point).length_squared(); + (distance <= COINCIDENT_EPSILON_SQ) + .then_some((distance, ParametricRef::point(handle, marker))) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, reference)| reference) +} + +fn point_segment_distance_squared(point: Vector3, start: Vector3, end: Vector3) -> f64 { + let edge = end - start; + let denominator = edge.length_squared(); + if denominator <= f64::EPSILON { + return (point - start).length_squared(); + } + let unit = ((point - start).dot(&edge) / denominator).clamp(0.0, 1.0); + (point - (start + edge * unit)).length_squared() +} + +/// Resolve the whole curve or the picked straight polyline segment used by a +/// point-to-curve Coincident relation. +pub(crate) fn parametric_curve_ref_for_pick( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + match entity { + acadrust::EntityType::Line(_) + | acadrust::EntityType::Circle(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Ellipse(_) + | acadrust::EntityType::Spline(_) => Some(ParametricRef::whole(handle)), + acadrust::EntityType::LwPolyline(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + (0..polyline.vertices.len()) + .filter_map(|index| { + let start = vertices.get(index).copied()?; + let end = vertices + .get(index + 1) + .copied() + .or_else(|| polyline.is_closed.then(|| vertices.first().copied()).flatten())?; + Some((point_segment_distance_squared(world_point, start, end), index)) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, index)| ParametricRef::segment(handle, index)) + } + acadrust::EntityType::Polyline2D(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + (0..polyline.vertices.len()) + .filter_map(|index| { + let start = vertices.get(index).copied()?; + let end = vertices + .get(index + 1) + .copied() + .or_else(|| polyline.is_closed().then(|| vertices.first().copied()).flatten())?; + Some((point_segment_distance_squared(world_point, start, end), index)) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, index)| ParametricRef::segment(handle, index)) + } + _ => None, + } +} + impl ConstraintKind { /// The short symbol a constraint glyph shows — matches the existing /// ribbon icons (`crate::modules::parametric::{tools,value}`) for @@ -568,6 +752,49 @@ pub(crate) fn glyph_placement( } impl super::Scene { + /// Expand a set of entities to the full enabled constraint component in + /// one scope. Whole-object translations use this to keep every connected + /// entity's own shape while moving the assembly. + pub(crate) fn parametric_connected_handles( + &self, + scope: ParametricScope, + seeds: &[Handle], + ) -> Vec { + let mut ordered = seeds.to_vec(); + let mut found: std::collections::HashSet<_> = seeds.iter().copied().collect(); + let Some(set) = self.parametric_constraint_set(scope) else { + return ordered; + }; + loop { + let mut added = false; + for constraint in set.constraints.iter().filter(|constraint| { + constraint.enabled + && matches!( + constraint.kind, + ConstraintKind::Coincident | ConstraintKind::PointOnCurve + ) + }) { + if !constraint + .refs + .iter() + .any(|reference| found.contains(&reference.entity)) + { + continue; + } + for reference in &constraint.refs { + if found.insert(reference.entity) { + ordered.push(reference.entity); + added = true; + } + } + } + if !added { + break; + } + } + ordered + } + pub fn is_parametric_constraint_visible( &self, scope: ParametricScope, @@ -724,6 +951,68 @@ impl super::Scene { mapped } + /// Infer only endpoint/vertex coincidences from the selected objects. + /// Unlike the broader automatic constraint pass, this includes spline, + /// ellipse, and polyline vertices while leaving centers and midpoints to + /// their dedicated constraint kinds. + pub fn inferred_coincident_constraints( + &self, + scope: ParametricScope, + handles: &[Handle], + ) -> Vec> { + let owner = scope.owner_handle(&self.document); + let mut sources = Vec::new(); + let mut seen_handles = std::collections::HashSet::new(); + for handle in handles.iter().copied() { + if !seen_handles.insert(handle) { + continue; + } + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if entity.common().owner_handle != owner { + continue; + } + for (marker, point) in super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + { + sources.push((ParametricRef::point(handle, marker as i32), point)); + } + } + + let existing = self.parametric_constraint_set(scope); + let mut inferred = Vec::new(); + for first in 0..sources.len() { + for second in first + 1..sources.len() { + if sources[first].0.entity == sources[second].0.entity + || (sources[first].1 - sources[second].1).length_squared() + > COINCIDENT_EPSILON_SQ + { + continue; + } + let refs = vec![sources[first].0, sources[second].0]; + let already_exists = existing.is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.kind == ConstraintKind::Coincident + && constraint.refs.len() == 2 + && (constraint.refs == refs + || (constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0])) + }) + }); + if !already_exists + && self + .validate_parametric_constraint(ConstraintKind::Coincident, &refs, None) + .is_ok() + { + inferred.push(refs); + } + } + } + inferred + } + pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { let [first, second] = handles else { return None; diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 20e9581bb..a92273eb0 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -45,6 +45,7 @@ enum EntityGeom { Polyline { points: Rc>, straight: Rc>, + arcs: Rc>>, closed: bool, }, Circle(GCircle), @@ -61,6 +62,12 @@ enum EntityGeom { }, } +#[derive(Clone, Copy)] +struct PolylineArc { + arc: GArc, + sweep: f64, +} + impl EntityGeom { /// The `cadkernel_constraints::geo::Point` for a marker on this entity — `None` for /// a marker this entity type/value doesn't support (see @@ -91,6 +98,7 @@ impl EntityGeom { points, straight, closed, + .. } = self else { return None; @@ -108,6 +116,13 @@ impl EntityGeom { }; Some(GLine { p1, p2 }) } + + fn arc_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { arcs, .. } = self else { + return None; + }; + arcs.get(index).copied().flatten() + } } /// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or @@ -131,8 +146,14 @@ fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { EntityGeom::Spline { .. } => CircleOrLine::Other, EntityGeom::Polyline { .. } => reference .segment_index() - .and_then(|index| g.line_segment(index)) - .map(CircleOrLine::Line) + .and_then(|index| { + g.line_segment(index) + .map(CircleOrLine::Line) + .or_else(|| { + g.arc_segment(index) + .map(|segment| CircleOrLine::Circle(segment.arc.circle)) + }) + }) .unwrap_or(CircleOrLine::Other), } } @@ -182,42 +203,114 @@ fn register_entity( sys.add_param(table.insertion_point.x, false), sys.add_param(table.insertion_point.y, false), ))), - EntityType::LwPolyline(polyline) => Some(EntityGeom::Polyline { - points: Rc::new( - super::dimension_assoc::source_points(entity) - .into_iter() - .map(|point| { - GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + EntityType::LwPolyline(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, }) - .collect(), - ), - straight: Rc::new( - polyline - .vertices - .iter() - .map(|vertex| vertex.bulge.abs() <= 1e-9) - .collect(), - ), - closed: polyline.is_closed, - }), - EntityType::Polyline2D(polyline) => Some(EntityGeom::Polyline { - points: Rc::new( - super::dimension_assoc::source_points(entity) - .into_iter() - .map(|point| { - GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed, + }) + } + EntityType::Polyline2D(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed() { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, }) - .collect(), - ), - straight: Rc::new( - polyline - .vertices - .iter() - .map(|vertex| vertex.bulge.abs() <= 1e-9) - .collect(), - ), - closed: polyline.is_closed(), - }), + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed(), + }) + } EntityType::Line(l) => { let p1 = GPoint::new( sys.add_param(l.start.x, false), @@ -439,6 +532,42 @@ fn resolve_constraint_point( ))); return Some(point); } + if let Some(segment) = reference + .segment_midpoint_index() + .and_then(|index| geometry.arc_segment(index)) + { + let (start, end) = { + let store = sys.store(); + ( + store.get(segment.arc.start_angle), + store.get(segment.arc.end_angle), + ) + }; + let parameter_value = (start + end) * 0.5; + let parameter = sys.add_param(parameter_value, false); + sys.add_constraint(Rc::new(CenterOfGravity::new( + parameter, + vec![segment.arc.start_angle, segment.arc.end_angle], + vec![0.5, 0.5], + ))); + let value = cadkernel_constraints::geo::Curve::value( + &segment.arc, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let curve = Rc::new(segment.arc); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + return Some(point); + } if marker != -2 { return None; } @@ -935,8 +1064,22 @@ fn build_constraint( } EntityGeom::Polyline { .. } => b .segment_index() - .and_then(|index| geom.line_segment(index)) - .map(|line| vec![Rc::new(PointOnLine::new(p, line)) as Rc]) + .and_then(|index| { + geom.line_segment(index) + .map(|line| { + vec![Rc::new(PointOnLine::new(p, line)) as Rc] + }) + .or_else(|| { + geom.arc_segment(index).map(|segment| { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new( + segment.arc.circle, + p, + zero, + )) as Rc] + }) + }) + }) .unwrap_or_default(), EntityGeom::Point(_) => Vec::new(), } @@ -1564,6 +1707,7 @@ fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1604,9 +1748,8 @@ fn solve_scope( // its endpoint. Not tracked in `owner`: these are solver-internal // bookkeeping, never redundant with anything a user-facing constraint // could name, so there's no `ConstraintId` for them to report against. - for geom in cache.values() { - let EntityGeom::Arc(arc) = geom else { continue }; - let curve = Rc::new(*arc); + let add_arc_rules = |sys: &mut System, arc: GArc, sweep: Option| { + let curve = Rc::new(arc); sys.add_constraint(Rc::new(CurveValue::new( arc.start, arc.start.x, @@ -1631,6 +1774,25 @@ fn solve_scope( curve, arc.end_angle, ))); + if let Some(sweep) = sweep { + let target = sys.add_param(sweep, true); + sys.add_constraint(Rc::new(Difference::new( + arc.start_angle, + arc.end_angle, + target, + ))); + } + }; + for geom in cache.values() { + match geom { + EntityGeom::Arc(arc) => add_arc_rules(&mut sys, *arc, None), + EntityGeom::Polyline { arcs, .. } => { + for segment in arcs.iter().flatten() { + add_arc_rules(&mut sys, segment.arc, Some(segment.sweep)); + } + } + _ => {} + } } // Ellipse rules (same motivation as arc rules, different mechanism): @@ -1667,6 +1829,66 @@ fn solve_scope( sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); } + // The first ordered Coincident selection is the anchor for this solve. + // Pin either its addressed point or every defining parameter of its whole + // curve temporarily; the relation remains persistent, while these pins do + // not enter the drawing's constraint graph. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|item| item.entity == reference.entity)) + { + continue; + } + let params_to_pin = if reference.marker.is_some() && reference.segment_index().is_none() { + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + .map(|point| vec![point.x, point.y]) + .unwrap_or_default() + } else { + match resolve_ref(document, &mut sys, &mut cache, *reference) { + Some(EntityGeom::Point(point)) => vec![point.x, point.y], + Some(EntityGeom::Line(line)) + | Some(EntityGeom::Ray(line)) + | Some(EntityGeom::XLine(line)) => { + vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y] + } + Some(EntityGeom::Polyline { points, .. }) => points + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + Some(EntityGeom::Circle(circle)) => { + vec![circle.center.x, circle.center.y, circle.rad] + } + Some(EntityGeom::Arc(arc)) => vec![ + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + arc.start_angle, + arc.end_angle, + ], + Some(EntityGeom::Ellipse(ellipse)) => vec![ + ellipse.center.x, + ellipse.center.y, + ellipse.focus1.x, + ellipse.focus1.y, + ellipse.radmin, + ], + Some(EntityGeom::Spline { curve, .. }) => curve + .poles + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + None => Vec::new(), + } + }; + for parameter in params_to_pin { + let value = sys.store().get(parameter); + let target = sys.add_param(value, true); + sys.add_constraint(Rc::new(Equal::new(parameter, target, 1.0))); + } + } + let partitions = sys.partition(); for sub in &partitions { solve_dl(sub, sys.store_mut()); @@ -1683,7 +1905,9 @@ fn solve_scope( .map(|g| match g { EntityGeom::Point(_) => 2, EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, - EntityGeom::Polyline { points, .. } => points.len() * 2, + EntityGeom::Polyline { points, arcs, .. } => { + points.len() * 2 + arcs.iter().flatten().count() * 5 + } EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both // angles) — same "raw, not netted against its own @@ -2151,9 +2375,18 @@ 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. + #[cfg(test)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_driven(changes, &[]) + } + + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2200,6 +2433,7 @@ impl Scene { &self.document, &self.named_parameters, &self.parametric_constraints[i], + driven_refs, ) else { continue; }; @@ -2237,7 +2471,7 @@ impl Scene { continue; } let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) + solve_scope(&self.document, &self.named_parameters, set, &[]) else { continue; }; diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index a28b2f98f..d58128961 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -80,6 +80,25 @@ pub fn properties_sectioned( } else { sections.extend(groups); } + if let Some(thickness) = entity_thickness(entity) { + let already_present = sections.iter().flat_map(|section| §ion.props).any(|property| { + property.field == "thickness" + || property.field == "sl_thickness" + || property.label == crate::t!("Thickness").as_ref() + }); + if !already_present { + if let Some(geometry) = sections + .iter_mut() + .find(|section| section.title == crate::t!("Geometry").as_ref()) + { + geometry.props.push(crate::entities::common::edit_prop( + crate::t!("Thickness").as_ref(), + "thickness", + thickness, + )); + } + } + } sections }