diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 4937b6382..b917fdeba 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3188,6 +3188,71 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddPerpendicularConstraint { + first, + second, + first_fixed, + second_start, + label, + } => { + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Perpendicular, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Perpendicular, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first_fixed, second_start], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::AddCoincidentConstraint { first, second, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 9bd3eeda5..e7b6467cd 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1366,7 +1366,46 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" + "QCONSTRAINT" | "GCPERPENDICULAR" => { + use crate::command::CmdResult; + use crate::modules::parametric::PerpendicularConstraintCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + let command = PerpendicularConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 2 { + self.command_line + .push_output("Select exactly two perpendicular-compatible objects."); + } else { + let picks = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + PerpendicularConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if picks.len() == 2 && picks[0].reference != picks[1].reference { + return Some(self.apply_cmd_result(CmdResult::AddPerpendicularConstraint { + first: picks[0].reference, + second: picks[1].reference, + first_fixed: picks[0].fixed_reference, + second_start: picks[1].start_reference, + label: "Perpendicular constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Perpendicular. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.", + ); + let command = PerpendicularConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + + "PCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { @@ -1383,9 +1422,6 @@ impl OpenCADStudio { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), - "QCONSTRAINT" => { - (ConstraintKind::Perpendicular, "Perpendicular constraint") - } "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), diff --git a/src/command.rs b/src/command.rs index 8368c9f8a..657809e4a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1477,6 +1477,16 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Adds an ordered perpendicular relation. The first picked direction and + /// the second direction's start point stay fixed during the initial solve; + /// those temporary anchors are not persisted as geometric constraints. + AddPerpendicularConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + first_fixed: crate::scene::parametric_constraints::ParametricRef, + second_start: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, /// Opens the Auto Constrain settings dialog from the selection prompt. OpenAutoConstrainSettings, /// Adds an ordered Coincident relation. Point/point selections create a @@ -2726,6 +2736,7 @@ mod constraint_registry_tests { "VCONSTRAINT", "PCONSTRAINT", "QCONSTRAINT", + "GCPERPENDICULAR", "ECONSTRAINT", "TCONSTRAINT", "NCONSTRAINT", diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs index 9f3189f8c..199912c87 100644 --- a/src/modules/parametric/geom_constraint.rs +++ b/src/modules/parametric/geom_constraint.rs @@ -16,7 +16,7 @@ impl GeomConstraintCommand { Some(match keyword { "H" | "HORIZONTAL" => "HCONSTRAINT", "V" | "VERTICAL" => "VCONSTRAINT", - "P" | "PERPENDICULAR" => "QCONSTRAINT", + "P" | "PERPENDICULAR" => "GCPERPENDICULAR", "PA" | "PARALLEL" => "PCONSTRAINT", "T" | "TANGENT" => "TCONSTRAINT", "SM" | "SMOOTH" => "SMOOTHCONSTRAINT", diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index fd82ee55b..45bff2fd0 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -5,12 +5,15 @@ mod coincident; mod equal_distance; mod geom_constraint; +#[path = "perpendicular.rs"] +mod perpendicular_command; 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 perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick}; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; diff --git a/src/modules/parametric/perpendicular.rs b/src/modules/parametric/perpendicular.rs new file mode 100644 index 000000000..b69b5d7d2 --- /dev/null +++ b/src/modules/parametric/perpendicular.rs @@ -0,0 +1,235 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::{directional_axis_endpoints, ParametricRef}; +use crate::t; + +#[derive(Clone, Copy)] +pub struct PerpendicularPick { + pub reference: ParametricRef, + pub fixed_reference: ParametricRef, + pub start_reference: ParametricRef, +} + +pub struct PerpendicularConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl PerpendicularConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Perpendicular. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.") + .into_owned(), + ) + } + + fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { + let start = DVec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z); + let end = DVec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z); + let direction = end - start; + if direction.length_squared() <= 1.0e-24 { + return f64::INFINITY; + } + (point - start).cross(direction.normalize()).length() + } + + fn ellipse_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let major = ParametricRef::ellipse_major_axis(handle); + let minor = ParametricRef::ellipse_minor_axis(handle); + let major_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); + let minor_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); + Some(if minor_distance < major_distance { minor } else { major }) + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (reference, fixed_reference, start_reference) = match entity { + EntityType::Line(_) => ( + ParametricRef::whole(handle), + ParametricRef::whole(handle), + ParametricRef::point(handle, 0), + ), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + ( + ParametricRef::segment(handle, index), + ParametricRef::segment(handle, index), + ParametricRef::point(handle, index as i32), + ) + } + EntityType::Text(_) | EntityType::MText(_) => ( + ParametricRef::text_baseline(handle), + ParametricRef::whole(handle), + ParametricRef::point(handle, 0), + ), + EntityType::Ellipse(_) => ( + Self::ellipse_reference(entity, handle, point)?, + ParametricRef::whole(handle), + ParametricRef::center(handle), + ), + _ => return None, + }; + Some(PerpendicularPick { + reference, + fixed_reference, + start_reference, + }) + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) | EntityType::Text(_) | EntityType::MText(_) => { + Self::picked_reference(entity, handle, DVec3::ZERO) + } + EntityType::Ellipse(_) => Some(PerpendicularPick { + reference: ParametricRef::ellipse_major_axis(handle), + fixed_reference: ParametricRef::whole(handle), + start_reference: ParametricRef::center(handle), + }), + EntityType::LwPolyline(polyline) => { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then_some(PerpendicularPick { + reference: ParametricRef::segment(handle, 0), + fixed_reference: ParametricRef::segment(handle, 0), + start_reference: ParametricRef::point(handle, 0), + }) + } + EntityType::Polyline2D(polyline) => { + let segment_count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then_some(PerpendicularPick { + reference: ParametricRef::segment(handle, 0), + fixed_reference: ParametricRef::segment(handle, 0), + start_reference: ParametricRef::point(handle, 0), + }) + } + _ => None, + } + } +} + +impl CadCommand for PerpendicularConstraintCommand { + fn name(&self) -> &'static str { + "GCPERPENDICULAR" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("GCPERPENDICULAR Select second object:").into_owned() + } else { + t!("GCPERPENDICULAR Select first object:").into_owned() + } + } + + fn needs_entity_pick(&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 Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(pick) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(pick); + return CmdResult::NeedPoint; + }; + if first.reference == pick.reference { + return Self::invalid_selection(); + } + CmdResult::AddPerpendicularConstraint { + first: first.reference, + second: pick.reference, + first_fixed: first.fixed_reference, + second_start: pick.start_reference, + label: "Perpendicular constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn command_preserves_pick_order_for_the_solver() { + let first = Handle::new(7); + let second = Handle::new(9); + let mut command = PerpendicularConstraintCommand::new(); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(first, DVec3::ZERO), + CmdResult::NeedPoint + )); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 2.0, 0.0), + ))); + let CmdResult::AddPerpendicularConstraint { + first: first_ref, + second: second_ref, + first_fixed, + second_start, + .. + } = command.on_entity_pick(second, DVec3::ZERO) + else { + panic!("second pick must create the constraint"); + }; + assert_eq!(first_ref, ParametricRef::whole(first)); + assert_eq!(second_ref, ParametricRef::whole(second)); + assert_eq!(first_fixed, ParametricRef::whole(first)); + assert_eq!(second_start, ParametricRef::point(second, 0)); + } +} diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 9a0a57293..49f211727 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -53,7 +53,7 @@ pub mod perpendicular { icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/perpendicular.svg" )), - event: ModuleEvent::Command("QCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCPERPENDICULAR".to_string()), } } } @@ -164,6 +164,7 @@ inventory::submit!(crate::command::CommandRegistration { "VCONSTRAINT", "PCONSTRAINT", "QCONSTRAINT", + "GCPERPENDICULAR", "ECONSTRAINT", "TCONSTRAINT", "NCONSTRAINT", diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 592f709f1..f4d755674 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -43,6 +43,7 @@ struct EntityNodes { geometry_node_id: i32, points: FxHashMap, segments: FxHashMap, + axes: FxHashMap, } /// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, @@ -396,6 +397,52 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } + /// Returns a derived line node for a text baseline or ellipse axis. These + /// are real native curve nodes tied to the source entity dependency, so + /// the selected direction survives save/reopen instead of collapsing to + /// the entity insertion/center point. + fn directional_axis_node(&mut self, reference: ParametricRef) -> Option { + let marker = reference.marker?; + reference.directional_axis()?; + if let Some(node_id) = self + .entities + .get(&reference.entity) + .and_then(|entity| entity.axes.get(&marker)) + { + return Some(*node_id); + } + let entity = self.document.get_entity(reference.entity)?; + let [start, end] = + super::parametric_constraints::directional_axis_endpoints(entity, reference)?; + let delta = end - start; + if delta.length_squared() <= 1.0e-24 { + return None; + } + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: delta.normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + self.entities + .entry(reference.entity) + .or_default() + .axes + .insert(marker, node_id); + if !self.referenced_entities.contains(&reference.entity) { + self.referenced_entities.push(reference.entity); + } + Some(node_id) + } + /// Returns the point-node id for a line endpoint or curve center, /// creating it the first time the marker is referenced. fn point_node(&mut self, handle: Handle, marker: i32) -> Option { @@ -498,6 +545,9 @@ impl<'a> GroupBuilder<'a> { /// The node id `ParametricRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. fn ref_node(&mut self, r: ParametricRef) -> Option { + if r.directional_axis().is_some() { + return self.directional_axis_node(r); + } if r.segment_midpoint_index().is_some() { return self.point_node(r.entity, r.marker?); } @@ -1553,6 +1603,10 @@ fn dependency_entity( ), AssocConstraintNodeData::Point { .. }, ) => true, + ( + Some(EntityType::Text(_) | EntityType::MText(_) | EntityType::Ellipse(_)), + AssocConstraintNodeData::BoundedLine { .. }, + ) => true, (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, ( Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), @@ -1643,6 +1697,52 @@ fn polyline_segment_reference( .map(|index| ParametricRef::segment(entity, index)) } +fn directional_axis_reference( + document: &CadDocument, + entity: Handle, + data: &AssocConstraintNodeData, + work_plane: &[Vector3; 3], +) -> Option { + let AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } = data + else { + return None; + }; + match document.get_entity(entity)? { + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(entity)) + } + EntityType::Ellipse(ellipse) => { + let [origin, axis_x, axis_y] = *work_plane; + let normal = Vector3::new( + axis_x.y * axis_y.z - axis_x.z * axis_y.y, + axis_x.z * axis_y.x - axis_x.x * axis_y.z, + axis_x.x * axis_y.y - axis_x.y * axis_y.x, + ); + let to_world = |point: Vector3| { + origin + axis_x * point.x + axis_y * point.y + normal * point.z + }; + let direction = (to_world(*end_point) - to_world(*start_point)).normalize(); + let major = ellipse.major_axis.normalize(); + let ellipse_normal = ellipse.normal.normalize(); + let minor = Vector3::new( + ellipse_normal.y * major.z - ellipse_normal.z * major.y, + ellipse_normal.z * major.x - ellipse_normal.x * major.z, + ellipse_normal.x * major.y - ellipse_normal.y * major.x, + ); + if direction.dot(&minor).abs() > direction.dot(&major).abs() { + Some(ParametricRef::ellipse_minor_axis(entity)) + } else { + Some(ParametricRef::ellipse_major_axis(entity)) + } + } + _ => None, + } +} + fn numeric_value(value: &AssocEvalVariant) -> Option { match value.value { AssocEvalValue::Real(value) => Some(value), @@ -1927,6 +2027,14 @@ pub(super) fn native_constraint_set( if let Some(entity) = dependency_entity(document, dependency, &node.data) { let reference = polyline_segment_reference(document, entity, &node.data, &group.work_plane) + .or_else(|| { + directional_axis_reference( + document, + entity, + &node.data, + &group.work_plane, + ) + }) .unwrap_or_else(|| { if matches!(node.data, AssocConstraintNodeData::Point { .. }) { ParametricRef::point(entity, 0) @@ -2337,7 +2445,7 @@ fn materialize_scope( continue; }; if entity_nodes.geometry_node_id == 0 { - if entity_nodes.segments.is_empty() { + if entity_nodes.segments.is_empty() && entity_nodes.axes.is_empty() { continue; } } @@ -2346,6 +2454,7 @@ fn materialize_scope( for node_id in std::iter::once(entity_nodes.geometry_node_id) .filter(|node_id| *node_id != 0) .chain(entity_nodes.segments.values().copied()) + .chain(entity_nodes.axes.values().copied()) { if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) { set_geometry_dependency(&mut node.data, dep_handle); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 3aa02a4d6..467f8d779 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -28,6 +28,16 @@ pub struct ParametricRef { const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; +const ELLIPSE_MAJOR_AXIS_MARKER: i32 = -4; +const ELLIPSE_MINOR_AXIS_MARKER: i32 = -5; +const TEXT_BASELINE_MARKER: i32 = -6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DirectionalAxis { + TextBaseline, + EllipseMajor, + EllipseMinor, +} impl ParametricRef { pub fn whole(entity: Handle) -> Self { @@ -81,6 +91,99 @@ impl ParametricRef { (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) .then(|| (POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - marker) as usize) } + + /// Select the displayed baseline of a Text or MText entity. + pub fn text_baseline(entity: Handle) -> Self { + Self { + entity, + marker: Some(TEXT_BASELINE_MARKER), + } + } + + /// Select the major axis of an ellipse or elliptical arc. + pub fn ellipse_major_axis(entity: Handle) -> Self { + Self { + entity, + marker: Some(ELLIPSE_MAJOR_AXIS_MARKER), + } + } + + /// Select the minor axis of an ellipse or elliptical arc. + pub fn ellipse_minor_axis(entity: Handle) -> Self { + Self { + entity, + marker: Some(ELLIPSE_MINOR_AXIS_MARKER), + } + } + + pub(crate) fn directional_axis(self) -> Option { + match self.marker? { + TEXT_BASELINE_MARKER => Some(DirectionalAxis::TextBaseline), + ELLIPSE_MAJOR_AXIS_MARKER => Some(DirectionalAxis::EllipseMajor), + ELLIPSE_MINOR_AXIS_MARKER => Some(DirectionalAxis::EllipseMinor), + _ => None, + } + } +} + +/// The finite guide used to pick, display and serialize a directional text or +/// ellipse reference. Constraint equations treat the guide as an infinite +/// line; its finite length only makes selection and native persistence stable. +pub(crate) fn directional_axis_endpoints( + entity: &acadrust::EntityType, + reference: ParametricRef, +) -> Option<[Vector3; 2]> { + match (entity, reference.directional_axis()?) { + (acadrust::EntityType::Text(text), DirectionalAxis::TextBaseline) => { + use acadrust::entities::TextHorizontalAlignment as Alignment; + + if matches!( + text.horizontal_alignment, + Alignment::Aligned | Alignment::Fit + ) { + if let Some(end) = text.alignment_point.filter(|end| { + (*end - text.insertion_point).length_squared() > 1.0e-18 + }) { + return Some([text.insertion_point, end]); + } + } + let length = text.height.abs().max(1.0); + Some([ + text.insertion_point, + text.insertion_point + + Vector3::new(text.rotation.cos(), text.rotation.sin(), 0.0) * length, + ]) + } + (acadrust::EntityType::MText(text), DirectionalAxis::TextBaseline) => { + let length = text.height.abs().max(1.0); + Some([ + text.insertion_point, + text.insertion_point + + Vector3::new(text.rotation.cos(), text.rotation.sin(), 0.0) * length, + ]) + } + (acadrust::EntityType::Ellipse(ellipse), axis) => { + let major_length = ellipse.major_axis.length(); + if major_length <= 1.0e-12 { + return None; + } + let vector = match axis { + DirectionalAxis::EllipseMajor => ellipse.major_axis, + DirectionalAxis::EllipseMinor => { + let major = ellipse.major_axis / major_length; + let normal = ellipse.normal.normalize(); + Vector3::new( + normal.y * major.z - normal.z * major.y, + normal.z * major.x - normal.x * major.z, + normal.x * major.y - normal.y * major.x, + ) * (major_length * ellipse.minor_axis_ratio) + } + DirectionalAxis::TextBaseline => return None, + }; + (vector.length_squared() > 1.0e-24).then_some([ellipse.center, ellipse.center + vector]) + } + _ => None, + } } /// Grabbed points are exact kernel inputs; the solver anchors the remaining @@ -702,6 +805,11 @@ fn glyph_placement_for_reference( .unwrap_or(Vector3::UNIT_Y) }; let line_normal = |line: &acadrust::entities::Line| segment_normal(line.start, line.end); + if r.directional_axis().is_some() { + let [start, end] = directional_axis_endpoints(entity, r)?; + let anchor = (start + end) * 0.5; + return Some((anchor, segment_normal(start, end))); + } if let Some(segment) = r.segment_index() { let anchor = resolve_point( entity, @@ -826,7 +934,15 @@ fn constraint_reference_curve_xy( document: &acadrust::CadDocument, reference: ParametricRef, ) -> Option { - let curve = crate::entities::curve::entity_curve_xy(document.get_entity(reference.entity)?)?; + let entity = document.get_entity(reference.entity)?; + if reference.directional_axis().is_some() { + let [start, end] = directional_axis_endpoints(entity, reference)?; + return Some(cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x, start.y], + end: [end.x, end.y], + })); + } + let curve = crate::entities::curve::entity_curve_xy(entity)?; reference .segment_index() .map(|index| curve.segments().into_iter().nth(index)) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 336aa718f..8c3619c90 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -41,6 +41,10 @@ use super::{ChangeKind, Scene}; #[derive(Clone)] enum EntityGeom { Point(GPoint), + /// A text insertion point plus its displayed baseline direction. The + /// second point is a solver guide only; write-back converts it to the + /// entity's rotation/alignment fields. + TextLine(GLine), Line(GLine), Ray(GLine), XLine(GLine), @@ -78,6 +82,7 @@ impl EntityGeom { fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { (EntityGeom::Point(point), 0) => Some(*point), + (EntityGeom::TextLine(line), 0) => Some(line.p1), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), (EntityGeom::Ray(l), 0) => Some(l.p1), @@ -142,7 +147,10 @@ fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => CircleOrLine::Line(l), + EntityGeom::TextLine(l) + | EntityGeom::Line(l) + | EntityGeom::Ray(l) + | EntityGeom::XLine(l) => CircleOrLine::Line(l), EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), EntityGeom::Point(_) => CircleOrLine::Other, EntityGeom::Spline { .. } => CircleOrLine::Other, @@ -185,14 +193,20 @@ fn register_entity( sys.add_param(insert.insert_point.x, false), sys.add_param(insert.insert_point.y, false), ))), - EntityType::Text(text) => Some(EntityGeom::Point(GPoint::new( - sys.add_param(text.insertion_point.x, false), - sys.add_param(text.insertion_point.y, false), - ))), - EntityType::MText(text) => Some(EntityGeom::Point(GPoint::new( - sys.add_param(text.insertion_point.x, false), - sys.add_param(text.insertion_point.y, false), - ))), + EntityType::Text(_) | EntityType::MText(_) => { + let reference = ParametricRef::text_baseline(handle); + let [start, end] = super::parametric_constraints::directional_axis_endpoints( + entity, + reference, + )?; + Some(EntityGeom::TextLine(GLine { + p1: GPoint::new( + sys.add_param(start.x, false), + sys.add_param(start.y, false), + ), + p2: GPoint::new(sys.add_param(end.x, false), sys.add_param(end.y, false)), + })) + } EntityType::AttributeDefinition(attribute) => Some(EntityGeom::Point(GPoint::new( sys.add_param(attribute.insertion_point.x, false), sys.add_param(attribute.insertion_point.y, false), @@ -876,6 +890,7 @@ fn build_constraint( EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), EntityGeom::Point(_) + | EntityGeom::TextLine(_) | EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) @@ -957,16 +972,53 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(fixed), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + let directional_line = + |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + let minor_axis = matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::EllipseMinor) + ); + let line = match geometry { + EntityGeom::TextLine(line) + if matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) + ) => line, + EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) + if r.marker.is_none() => line, + EntityGeom::Polyline { .. } => { + geometry.line_segment(r.segment_index()?)? + } + EntityGeom::Ellipse(ellipse) + if matches!( + r.directional_axis(), + Some( + super::parametric_constraints::DirectionalAxis::EllipseMajor + | super::parametric_constraints::DirectionalAxis::EllipseMinor + ) + ) => GLine { + p1: ellipse.center, + p2: ellipse.focus1, + }, + _ => return None, + }; + Some((line, minor_axis)) + }; + let (Some((fixed, fixed_minor)), Some((moving, moving_minor))) = + (directional_line(sys, cache, *a), directional_line(sys, cache, *b)) else { return Vec::new(); }; - vec![Rc::new(PerpendicularConstraint::new( - sys.store(), - moving, - fixed, - ))] + if fixed_minor ^ moving_minor { + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] + } else { + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + moving, + fixed, + ))] + } } ConstraintKind::Equal => { let [a, b] = c.refs.as_slice() else { @@ -1059,7 +1111,10 @@ fn build_constraint( return Vec::new(); }; match geom { - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + EntityGeom::TextLine(l) + | EntityGeom::Line(l) + | EntityGeom::Ray(l) + | EntityGeom::XLine(l) => { vec![Rc::new(PointOnLine::new(p, l))] } EntityGeom::Circle(circ) => { @@ -1200,7 +1255,10 @@ fn build_constraint( }) .collect() } - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + EntityGeom::TextLine(l) + | EntityGeom::Line(l) + | EntityGeom::Ray(l) + | EntityGeom::XLine(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); ( @@ -1731,6 +1789,12 @@ type SolveResult = ( fn retained_line_length(entity: &EntityType, segment: Option) -> Option { match (entity, segment) { (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::Text(_) | EntityType::MText(_), None) => { + let reference = ParametricRef::text_baseline(entity.common().handle); + let [start, end] = + super::parametric_constraints::directional_axis_endpoints(entity, reference)?; + Some((end - start).length()) + } (EntityType::LwPolyline(polyline), Some(index)) => { let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; let a = world.vertices.get(index)?.location; @@ -2027,7 +2091,7 @@ fn solve_scope( if retain_size { for (handle, geom) in &cache { match geom { - EntityGeom::Line(line) if retain_lengths => { + EntityGeom::Line(line) | EntityGeom::TextLine(line) if retain_lengths => { let current_length = { let store = sys.store(); let dx = store.get(line.p2.x) - store.get(line.p1.x); @@ -2088,6 +2152,26 @@ fn solve_scope( let target = sys.add_param(radius, true); sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); } + EntityGeom::Ellipse(ellipse) => { + let (focus_distance, minor_radius) = { + let store = sys.store(); + let dx = store.get(ellipse.focus1.x) - store.get(ellipse.center.x); + let dy = store.get(ellipse.focus1.y) - store.get(ellipse.center.y); + ((dx * dx + dy * dy).sqrt(), store.get(ellipse.radmin)) + }; + let focus_target = sys.add_param(focus_distance, true); + let minor_target = sys.add_param(minor_radius, true); + sys.add_constraint(Rc::new(P2PDistance::new( + ellipse.center, + ellipse.focus1, + focus_target, + ))); + sys.add_constraint(Rc::new(Equal::new( + ellipse.radmin, + minor_target, + 1.0, + ))); + } _ => {} } } @@ -2156,13 +2240,10 @@ fn solve_scope( // `focus1 - center` (which is what `major_axis` direction/length and // `minor_axis_ratio` are actually computed from on write-back) changes // anyway, making the ellipse appear to rotate and reshape even though - // no shape parameter was itself pulled on. Pinning `focus1 - center` to - // its pre-solve (seed) value forces `focus1` to translate rigidly with - // `center`, keeping orientation/eccentricity invariant unless something - // actually constrains `focus1`/`radmin` directly (only `Fixed` does - // today). `radmin` needs no equivalent rule — as a lone scalar it isn't - // coupled to `center`'s movement, so it already stays put on its own. - for geom in cache.values() { + // no shape parameter was itself pulled on. Most ellipse relations keep + // that vector exact. A directional axis relation instead preserves its + // length while allowing the vector to rotate into the requested angle. + for (handle, geom) in &cache { let EntityGeom::Ellipse(el) = geom else { continue; }; @@ -2175,10 +2256,67 @@ fn solve_scope( store.get(el.focus1.y), ) }; - let dx = sys.add_param(fx - cx, true); - let dy = sys.add_param(fy - cy, true); - sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); - sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + let directional = set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Perpendicular + && constraint.refs.iter().any(|reference| { + reference.entity == *handle && reference.directional_axis().is_some() + }) + }); + if directional && !retain_size { + let distance = sys.add_param((fx - cx).hypot(fy - cy), true); + sys.add_constraint(Rc::new(P2PDistance::new( + el.center, + el.focus1, + distance, + ))); + let minor_value = sys.store().get(el.radmin); + let minor = sys.add_param(minor_value, true); + sys.add_constraint(Rc::new(Equal::new(el.radmin, minor, 1.0))); + } else if !directional { + let dx = sys.add_param(fx - cx, true); + let dy = sys.add_param(fy - cy, true); + sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); + sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + } + } + + // Text keeps its displayed baseline rigid during ordinary point edits. + // A perpendicular baseline reference keeps only the span length so the + // command can rotate the text about its insertion point. + for (handle, geom) in &cache { + let EntityGeom::TextLine(line) = geom else { + continue; + }; + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + let directional = set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Perpendicular + && constraint.refs.iter().any(|reference| { + reference.entity == *handle + && matches!( + reference.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) + ) + }) + }); + if directional && !retain_size { + let length = sys.add_param((x2 - x1).hypot(y2 - y1), true); + sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, length))); + } else if !directional { + let dx = sys.add_param(x2 - x1, true); + let dy = sys.add_param(y2 - y1, true); + sys.add_constraint(Rc::new(Difference::new(line.p1.x, line.p2.x, dx))); + sys.add_constraint(Rc::new(Difference::new(line.p1.y, line.p2.y, dy))); + } } // Temporarily make each requested anchor a fixed kernel input. Marking the @@ -2205,7 +2343,8 @@ fn solve_scope( } 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::TextLine(line)) + | 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] @@ -2261,7 +2400,10 @@ fn solve_scope( .values() .map(|g| match g { EntityGeom::Point(_) => 2, - EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, + EntityGeom::TextLine(_) + | EntityGeom::Line(_) + | EntityGeom::Ray(_) + | EntityGeom::XLine(_) => 4, EntityGeom::Polyline { points, arcs, .. } => { points.len() * 2 + arcs.iter().flatten().count() * 5 } @@ -2343,31 +2485,72 @@ fn solve_scope( results.push((handle, EntityType::Insert(updated))); } } - (EntityType::Text(text), EntityGeom::Point(geometry)) => { + (EntityType::Text(text), EntityGeom::TextLine(geometry)) => { let mut updated = text.clone(); let next = Vector3::new( - store.get(geometry.x), - store.get(geometry.y), + store.get(geometry.p1.x), + store.get(geometry.p1.y), + text.insertion_point.z, + ); + let end = Vector3::new( + store.get(geometry.p2.x), + store.get(geometry.p2.y), text.insertion_point.z, ); let delta = next - text.insertion_point; - if delta.length() > MOVE_EPS { + let direction = end - next; + let rotation = direction.y.atan2(direction.x); + let aligned = matches!( + text.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + | acadrust::entities::TextHorizontalAlignment::Fit + ); + let current_rotation = if aligned { + text.alignment_point + .filter(|point| (*point - text.insertion_point).length_squared() > 1.0e-18) + .map(|point| { + (point.y - text.insertion_point.y) + .atan2(point.x - text.insertion_point.x) + }) + .unwrap_or(text.rotation) + } else { + text.rotation + }; + if delta.length() > MOVE_EPS + || (rotation - current_rotation).abs() > MOVE_EPS + { updated.insertion_point = next; - if let Some(alignment) = &mut updated.alignment_point { - *alignment = *alignment + delta; + if aligned { + updated.alignment_point = Some(end); + } else { + if let Some(alignment) = &mut updated.alignment_point { + *alignment = *alignment + delta; + } + updated.rotation = rotation; } results.push((handle, EntityType::Text(updated))); } } - (EntityType::MText(text), EntityGeom::Point(geometry)) => { + (EntityType::MText(text), EntityGeom::TextLine(geometry)) => { let mut updated = text.clone(); let next = Vector3::new( - store.get(geometry.x), - store.get(geometry.y), + store.get(geometry.p1.x), + store.get(geometry.p1.y), text.insertion_point.z, ); - if (next - text.insertion_point).length() > MOVE_EPS { + let dx = store.get(geometry.p2.x) - next.x; + let dy = store.get(geometry.p2.y) - next.y; + let rotation = dy.atan2(dx); + if (next - text.insertion_point).length() > MOVE_EPS + || (rotation - text.rotation).abs() > MOVE_EPS + { updated.insertion_point = next; + updated.rotation = rotation; + updated.dwg_x_direction = Some(Vector3::new( + rotation.cos(), + rotation.sin(), + 0.0, + )); results.push((handle, EntityType::MText(updated))); } }