diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 70f498228..9c3d81cd1 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2186,10 +2186,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.apply_inferred_constraints(i, &handles); @@ -3068,44 +3075,167 @@ 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_policy( + &changes, + &[first_ref], + self.constraint_solve_mode, + ); + 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, @@ -8443,8 +8573,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", }); @@ -8460,6 +8599,21 @@ mod parametric_constraint_undo_tests { entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines" ); + let Some(acadrust::EntityType::Line(first)) = + app.tabs[app.active_tab].scene.document.get_entity(a) + else { + panic!("expected first line"); + }; + let Some(acadrust::EntityType::Line(second)) = + app.tabs[app.active_tab].scene.document.get_entity(b) + else { + panic!("expected second line"); + }; + assert!( + (first.end - acadrust::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 + ); + assert!((second.start - first.end).length() < 1.0e-7); + assert!((second.length() - 5.0).abs() < 1.0e-7); } /// A pick that doesn't land near any real point (no object snap match) @@ -8472,8 +8626,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 7ef3f7b36..d021b1d6b 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1331,13 +1331,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 ac77596d2..a942c5ca6 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 bdc482e3f..d88fa1727 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 { @@ -1468,14 +1479,21 @@ pub enum CmdResult { }, /// 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. + /// 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 @@ -2683,6 +2701,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..0a13b7329 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,63 @@ impl CadCommand for CoincidentConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["CCONSTRAINT"] + names: &["CCONSTRAINT", "GCCOINCIDENT"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn multiple_keeps_the_first_curve_for_repeated_points() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Object"), + Some(CmdResult::NeedPoint) + )); + command.inject_picked_entity(EntityType::Line( + acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X), + )); + let handle = Handle::new(7); + assert!(matches!( + command.on_entity_pick(handle, DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_text_input("Multiple"), + Some(CmdResult::NeedPoint) + )); + + assert!(matches!( + command.on_point(DVec3::X), + CmdResult::AddCoincidentConstraint { + first: CoincidentPick { + handle: Some(found), + whole_curve: true, + .. + }, + multiple: true, + .. + } if found == handle + )); + } + + #[test] + fn automatic_mode_returns_the_completed_selection() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Autoconstrain"), + Some(CmdResult::NeedPoint) + )); + let handles = vec![Handle::new(3), Handle::new(5)]; + assert!(matches!( + command.on_selection_complete(handles.clone()), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_enter(), + CmdResult::AddAutoCoincidentConstraints { handles: found } if found == handles + )); + } +} diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs new file mode 100644 index 000000000..9f3189f8c --- /dev/null +++ b/src/modules/parametric/geom_constraint.rs @@ -0,0 +1,105 @@ +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"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooser_dispatches_an_existing_constraint_command() { + let mut command = GeomConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Parallel"), + Some(CmdResult::Dispatch(found)) if found == "PCONSTRAINT" + )); + assert!(matches!( + command.on_enter(), + CmdResult::Dispatch(found) if found == "GCCOINCIDENT" + )); + } +} diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 9539651ee..fd82ee55b 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/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 198a118c6..9c7fe40cb 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -400,6 +400,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, }; } @@ -417,21 +418,10 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } .segment_midpoint_index(); if let Some(segment) = segment { - let points = super::dimension_assoc::source_points(entity); - let closed = match entity { - acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, - acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), - _ => return None, - }; - let a = *points.get(segment)?; - let b = if segment + 1 < points.len() { - points[segment + 1] - } else if closed { - *points.first()? - } else { - return None; - }; - return Some((a + b) * 0.5); + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let point = planar.plane.point_at(curve.point_at(0.5)); + return Some(Vector3::new(point[0], point[1], point[2])); } if marker < 0 { return None; @@ -445,6 +435,63 @@ 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]))); + } + } + } + + if matches!( + entity, + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) + ) { + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + points.extend(planar.curve.segments().into_iter().enumerate().map( + |(index, curve)| { + let point = planar.plane.point_at(curve.point_at(0.5)); + ( + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, + Vector3::new(point[0], point[1], point[2]), + ) + }, + )); + } + } + 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. @@ -470,21 +517,63 @@ 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) +} + +/// Resolve the whole curve or the picked 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(_) | acadrust::EntityType::Polyline2D(_) => { + let segments = crate::entities::curve::entity_curve_xy(entity)?.segments(); + cadkernel::geom2d::nearest_of(segments.iter(), [world_point.x, world_point.y]) + .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 @@ -722,6 +811,49 @@ pub(crate) fn constraint_hover_points( } 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, @@ -1012,6 +1144,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; @@ -1714,4 +1908,55 @@ mod tests { Some(cadkernel::geom2d::Curve::Arc(_)) )); } + + #[test] + fn curve_pick_uses_the_bulged_segment_instead_of_its_chord() { + let mut scene = super::super::Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + acadrust::entities::LwVertex::from_coords(0.0, -4.0), + ]; + let handle = scene.add_entity(acadrust::EntityType::LwPolyline(polyline)); + + assert_eq!( + parametric_curve_ref_for_pick( + &scene.document, + ParametricScope::ModelSpace, + handle, + Vector3::new(5.0, -5.0, 0.0), + ), + Some(ParametricRef::segment(handle, 0)) + ); + } + + #[test] + fn move_expansion_follows_only_coincident_relations() { + let mut scene = super::super::Scene::new(); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Coincident, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 0)], + None, + ); + set.add( + ConstraintKind::PointOnCurve, + vec![ParametricRef::point(h(2), 0), ParametricRef::whole(h(3))], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(h(3)), ParametricRef::whole(h(4))], + None, + ); + + assert_eq!( + scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)]), + vec![h(1), h(2), h(3)] + ); + } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 9057a3410..c7ddea8c9 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(), } @@ -1660,13 +1803,10 @@ fn solve_scope( } EntityGeom::Polyline { points, - straight, closed, + .. } => { - for index in 0..straight.len() { - if !straight[index] { - continue; - } + for index in 0..points.len() { let Some(a) = points.get(index).copied() else { continue; }; @@ -1720,9 +1860,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, @@ -1747,6 +1886,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)); + } + } + _ => {} + } } // A point explicitly edited by a grip, STRETCH, or Properties is the @@ -1810,6 +1968,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()); @@ -1826,7 +2044,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 @@ -2531,6 +2751,26 @@ mod tests { .is_err()); } + #[test] + fn bulged_polyline_segment_registers_as_a_kernel_arc() { + let mut scene = Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + let handle = scene.add_entity(EntityType::LwPolyline(polyline)); + let mut system = cadkernel_constraints::system::System::new(); + let geometry = super::register_entity(&scene.document, &mut system, handle).unwrap(); + let segment = geometry.arc_segment(0).expect("expected an arc segment"); + + assert!((system.store().get(segment.arc.circle.rad) - 5.0).abs() < 1.0e-9); + assert!((segment.sweep - std::f64::consts::PI).abs() < 1.0e-9); + } + #[test] fn ray_and_construction_line_constraints_solve_through_the_kernel() { let mut scene = Scene::new(); diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index a28b2f98f..86a8e5ea4 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 } @@ -252,9 +271,8 @@ pub fn set_entity_xdata( } /// The extrusion thickness (DXF 39) of the entities that carry one, or `None` -/// for entity types that have none. Thickness is a per-entity field but is -/// surfaced in the General group (as in a standard properties palette), so -/// this bridges the two. +/// for entity types that have none. The Properties panel uses this to append +/// the shared field to each entity's Geometry group. pub fn entity_thickness(entity: &EntityType) -> Option { Some(match entity { EntityType::Arc(e) => e.thickness, @@ -382,3 +400,26 @@ pub fn grip_menu_point_value( pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) { EntityTypeOps::apply_transform(entity, t); } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn line_thickness_follows_the_entity_geometry_rows() { + let mut line = acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + line.thickness = 2.5; + let sections = properties_sectioned(Handle::NULL, &EntityType::Line(line), &[]); + + assert!(!sections[0] + .props + .iter() + .any(|property| property.field == "thickness")); + let geometry = sections + .iter() + .find(|section| section.props.iter().any(|property| property.field == "start_x")) + .unwrap(); + assert_eq!(geometry.props.last().unwrap().field, "thickness"); + } +}