diff --git a/src/command.rs b/src/command.rs index 797b27b4b..a79db230a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,7 +1212,7 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), - /// Commit a linear/aligned dimension using the drawing's association mode. + /// Commit a dimension using the drawing's association mode. /// Object-selection workflows may retain their source. CommitDimension { entity: EntityType, diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 1fe7ccfa5..3bbc1c237 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -1,7 +1,7 @@ // DIMDIAMETER command — diameter dimension for circles and arcs. use acadrust::entities::{Dimension, DimensionDiameter}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::{DVec3, Vec3}; @@ -22,14 +22,12 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - ArcPoint(DVec3), - TextPoint { center: DVec3, arc_pt: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct DiameterDimensionCommand { step: Step, - plane: WorkingPlane, /// Optional text that replaces the measured value (None = measurement). text_override: Option, /// True while the next typed line is captured as the text override. @@ -38,24 +36,28 @@ pub struct DiameterDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl DiameterDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } impl CadCommand for DiameterDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -64,48 +66,58 @@ impl CadCommand for DiameterDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMDIAMETER Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMDIAMETER Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMDIAMETER Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMDIAMETER Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMDIAMETER Specify center point:").into_owned(), - Step::ArcPoint(_) => t!("DIMDIAMETER Specify point on circle:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMDIAMETER Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => { + t!("DIMDIAMETER Select arc, circle, or polyline arc:").into_owned() + } + Step::DimLine(_) => { + t!("DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]:") + .into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::ArcPoint(pt); - CmdResult::NeedPoint - } - Step::ArcPoint(center) => { - self.step = Step::TextPoint { center, arc_pt: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, arc_pt } => { - let center = self.plane.to_local(center); - let arc_pt = self.plane.to_local(arc_pt); - let pt = self.plane.to_local(pt); - let mut dim = DimensionDiameter::new(v3(center), v3(arc_pt)); - dim.base.definition_point = v3(arc_pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let chord = source_plane.to_local(dvec(source.chord_at(pt.to_array()))); + let far_chord = source_plane.to_local(dvec(source.opposite_chord_at(pt.to_array()))); + let pt = source_plane.to_local(pt); + let mut dim = DimensionDiameter::new(v3(chord), v3(far_chord)); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); - dim.leader_length = arc_pt.distance(pt); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Diameter(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane + .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), + source: self.source_handle, + } } } } @@ -160,8 +172,17 @@ impl CadCommand for DiameterDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine(_)) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -173,14 +194,47 @@ impl CadCommand for DiameterDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + 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 { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::ArcPoint(center) => Some(preview_line(center.as_vec3(), pt)), - Step::TextPoint { center, arc_pt } => { - let far = center + (center - arc_pt); // opposite point on circle - Some(preview_line(far.as_vec3(), pt)) + Step::SelectObject => None, + Step::DimLine(source) => { + let chord = dvec(source.chord_at(pt.to_array())).as_vec3(); + let far_chord = dvec(source.opposite_chord_at(pt.to_array())).as_vec3(); + Some(preview_line(far_chord, chord, pt.as_vec3())) } } } @@ -190,7 +244,12 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } -fn preview_line(a: Vec3, b: Vec3) -> WireModel { +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { + let separator = [f32::NAN; 3]; WireModel { point_marker: None, taper_widths: Vec::new(), @@ -204,11 +263,17 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimdia_preview".into(), - points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]], + points: vec![ + far_chord.to_array(), + chord.to_array(), + separator, + chord.to_array(), + text.to_array(), + ], points_low: Vec::new(), color: WireModel::CYAN, selected: false, diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 2814695ea..5989179ae 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -8,10 +8,198 @@ use acadrust::EntityType; use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve as KernelCurve, }; +use cadkernel::space::Plane; use std::f64::consts::TAU; use super::{ChangeKind, Scene}; +#[derive(Clone, Copy, Debug)] +pub(crate) struct RadialSourceGeometry { + pub plane: Plane, + pub center: [f64; 2], + pub radius: f64, + pub start_angle: f64, + pub sweep: f64, + pub limited: bool, + pub marker: i32, +} + +impl RadialSourceGeometry { + pub(crate) fn center_world(self) -> Vector3 { + vector3(self.plane.point_at(self.center)) + } + + pub(crate) fn point_at_angle(self, angle: f64) -> Vector3 { + vector3(self.plane.point_at([ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ])) + } + + pub(crate) fn angle_at(self, point: DPoint) -> f64 { + let projected = self.plane.project(point).unwrap_or(self.center); + (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]) + } + + pub(crate) fn chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point)) + } + + pub(crate) fn opposite_chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point) + std::f64::consts::PI) + } + + fn contains_angle(self, angle: f64) -> bool { + if !self.limited { + return true; + } + signed_progress(self.start_angle, angle, self.sweep) + .is_some_and(|progress| progress <= self.sweep.abs() + 1e-10) + } + + fn distance_squared_to(self, point: DPoint) -> f64 { + let Some(projected) = self.plane.project(point) else { + return f64::INFINITY; + }; + let angle = (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]); + let radial = ((projected[0] - self.center[0]).hypot(projected[1] - self.center[1]) + - self.radius) + .abs(); + let candidate = if self.contains_angle(angle) { + [ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ] + } else { + let start = [ + self.center[0] + self.radius * self.start_angle.cos(), + self.center[1] + self.radius * self.start_angle.sin(), + ]; + let end_angle = self.start_angle + self.sweep; + let end = [ + self.center[0] + self.radius * end_angle.cos(), + self.center[1] + self.radius * end_angle.sin(), + ]; + if squared_2d(start, projected) <= squared_2d(end, projected) { + start + } else { + end + } + }; + let planar = if self.contains_angle(angle) { + radial * radial + } else { + squared_2d(candidate, projected) + }; + let world = self.plane.point_at(projected); + let dx = world[0] - point[0]; + let dy = world[1] - point[1]; + let dz = world[2] - point[2]; + planar + dx * dx + dy * dy + dz * dz + } +} + +type DPoint = [f64; 3]; + +fn vector3(point: DPoint) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> DPoint { + [point.x, point.y, point.z] +} + +fn squared_2d(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +fn signed_progress(start: f64, angle: f64, sweep: f64) -> Option { + if !start.is_finite() || !angle.is_finite() || !sweep.is_finite() || sweep.abs() <= 1e-12 { + return None; + } + Some(if sweep > 0.0 { + (angle - start).rem_euclid(TAU) + } else { + (start - angle).rem_euclid(TAU) + }) +} + +fn radial_candidates(entity: &EntityType) -> Vec { + let Some(planar) = crate::entities::curve::entity_curve(entity) else { + return Vec::new(); + }; + match planar.curve { + KernelCurve::Circle(circle) => vec![RadialSourceGeometry { + plane: planar.plane, + center: circle.centre, + radius: circle.radius, + start_angle: 0.0, + sweep: TAU, + limited: false, + marker: 0, + }], + KernelCurve::Arc(arc) => vec![RadialSourceGeometry { + plane: planar.plane, + center: arc.centre, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep(), + limited: true, + marker: 0, + }], + KernelCurve::Polyline(polyline) => (0..polyline.vertices.len()) + .filter_map(|index| { + let arc = polyline.segment_arc(index)?; + Some(RadialSourceGeometry { + plane: planar.plane, + center: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep, + limited: true, + marker: index as i32, + }) + }) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn radial_source_at( + entity: &EntityType, + point: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) +} + +fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { + radial_candidates(entity) + .into_iter() + .find(|candidate| candidate.marker == marker) +} + +fn radial_source_matching( + entity: &EntityType, + center: Vector3, + radius: f64, + chord: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) +} + fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { let dx = first.x - second.x; let dy = first.y - second.y; @@ -172,6 +360,71 @@ pub(crate) fn dimension_is_associative( }) } +pub(crate) fn radial_extension_points( + document: &acadrust::CadDocument, + dimension: Handle, + gap: f64, + extension: f64, +) -> Option> { + let EntityType::Dimension(Dimension::Diameter(diameter)) = document.get_entity(dimension)? + else { + return None; + }; + let association = document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + })?; + let reference = association.references[0].first()?; + let source = document.get_entity(*reference.xrefs.first()?)?; + let radial = radial_source_for_marker(source, reference.main_gs_marker)?; + if !radial.limited || radial.radius <= 1e-12 { + return None; + } + let target = radial.angle_at(dpoint(diameter.angle_vertex)); + if radial.contains_angle(target) { + return None; + } + + let end = radial.start_angle + radial.sweep; + let direction = radial.sweep.signum(); + let from_start = if direction > 0.0 { + (radial.start_angle - target).rem_euclid(TAU) + } else { + (target - radial.start_angle).rem_euclid(TAU) + }; + let from_end = if direction > 0.0 { + (target - end).rem_euclid(TAU) + } else { + (end - target).rem_euclid(TAU) + }; + let (origin, extension_direction, span) = if from_start <= from_end { + (radial.start_angle, -direction, from_start) + } else { + (end, direction, from_end) + }; + let gap_angle = gap.max(0.0) / radial.radius; + if span <= gap_angle + 1e-10 { + return None; + } + let extension_angle = extension.max(0.0) / radial.radius; + let visible_span = span - gap_angle + extension_angle; + let first_angle = origin + extension_direction * gap_angle; + let segments = (visible_span / (5.0_f64.to_radians())).ceil().max(1.0) as usize; + Some( + (0..=segments) + .map(|index| { + let fraction = index as f64 / segments as f64; + radial.point_at_angle(first_angle + extension_direction * visible_span * fraction) + }) + .collect(), + ) +} + impl Scene { pub(crate) fn attach_dimension_association( &mut self, @@ -182,6 +435,42 @@ impl Scene { else { return; }; + if let Dimension::Diameter(diameter) = entity { + let Some(source) = sources.into_iter().flatten().next() else { + return; + }; + let Some(source_entity) = self.document.get_entity(source) else { + return; + }; + let center = diameter.center(); + let radius = diameter.measurement() * 0.5; + let Some(radial) = radial_source_matching( + source_entity, + center, + radius, + diameter.angle_vertex, + ) else { + return; + }; + let angle = radial.angle_at(dpoint(diameter.angle_vertex)); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: diameter.angle_vertex, + ..AssocDimensionReference::default() + }; + self.store_dimension_association( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + [Some(source), None], + ); + return; + } let Some(source_data) = dimension_points(entity) else { return; }; @@ -235,6 +524,16 @@ impl Scene { } } + self.store_dimension_association(dimension, references, associativity, sources); + } + + fn store_dimension_association( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + sources: [Option; 2], + ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); object.handle = association_handle; @@ -270,6 +569,33 @@ impl Scene { else { return [None, None]; }; + if let Dimension::Diameter(diameter) = entity { + let measurement = diameter.measurement(); + let radius = measurement * 0.5; + let center = diameter.center(); + let tolerance = measurement.abs().max(1.0) * 1e-9; + let source = self + .document + .entities() + .filter(|candidate| candidate.common().handle != dimension) + .filter_map(|candidate| { + let radial = radial_source_matching( + candidate, + center, + radius, + diameter.angle_vertex, + )?; + let center_error = point_distance_squared(radial.center_world(), center); + let radius_error = (radial.radius - radius).powi(2); + (center_error + radius_error <= tolerance * tolerance).then_some(( + center_error + radius_error, + candidate.common().handle, + )) + }) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle); + return [source, None]; + } let Some(points) = dimension_points(entity) else { return [None, None]; }; @@ -318,13 +644,19 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { + let radial_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; + Some((radial, reference.osnap_distance)) + }); let first = association.references[0] .first() .and_then(|reference| resolve_reference(self, reference)); let second = association.references[1] .first() .and_then(|reference| resolve_reference(self, reference)); - if first.is_none() && second.is_none() { + if first.is_none() && second.is_none() && radial_source.is_none() { continue; } let Some(EntityType::Dimension(dimension)) = @@ -353,6 +685,25 @@ impl Scene { aligned.base.actual_measurement = aligned.measurement(); aligned.base.definition_point = aligned.definition_point; } + Dimension::Diameter(diameter) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = diameter.angle_vertex; + let new_chord = radial.point_at_angle(angle); + let new_far_chord = radial.point_at_angle(angle + std::f64::consts::PI); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + diameter.angle_vertex = new_chord; + diameter.definition_point = new_far_chord; + diameter.base.definition_point = new_far_chord; + diameter.base.text_middle_point = diameter.base.text_middle_point + delta; + diameter.base.insertion_point = diameter.base.insertion_point + delta; + diameter.base.actual_measurement = diameter.measurement(); + } _ => continue, } refreshed.push((association.dimension, ChangeKind::Modified));