From 24b344b2b2f40148b8f68c9111b5cdea126f6212 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:23:21 +0300 Subject: [PATCH] Complete angular dimension interaction and associations --- src/app/command_driver.rs | 67 +++ src/app/mod.rs | 6 + src/app/mtext_editor.rs | 12 + src/app/update/file.rs | 7 + src/command.rs | 51 +++ src/modules/annotate/angular_dim.rs | 620 ++++++++++++++++++++++++---- src/scene/dimension_assoc.rs | 89 +++- 7 files changed, 777 insertions(+), 75 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1bff0d720..6845b7772 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -581,6 +581,36 @@ impl OpenCADStudio { let _ = self.apply_cmd_result(r); return; } + let accepts_points = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()); + if accepts_points { + if let Some((coord, kind)) = super::helpers::parse_coord(token) { + let ucs = self.tabs[i].active_ucs.clone(); + let wcs = match ( + matches!(kind, super::helpers::CoordKind::Relative), + self.last_point, + ) { + (true, Some(base)) => { + base + match &ucs { + Some(value) => super::helpers::ucs_rotate_vec(coord, value), + None => coord, + } + } + _ => match &ucs { + Some(value) => super::helpers::ucs_to_wcs(coord, value), + None => coord, + }, + }; + if self.command_point_allowed(i, wcs) { + self.last_point = Some(wcs); + self.push_ucs_to_cmd(i); + let _ = self.feed_command(StepInput::Point(wcs)); + } + return; + } + } if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) { let handle = Handle::new(v); let pt = self.tabs[i] @@ -1257,6 +1287,30 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } + CmdResult::CommitAssociativeDimensionDetailed { entity, sources } => { + let label = self.history_label_from_active_cmd(i, "DIMENSION"); + let pending = self.begin_undo(i, label, 1, false); + if let Some(handle) = self.commit_entity_handle(entity) { + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + let mut changed = vec![(handle, crate::scene::ChangeKind::Modified)]; + changed.extend(sources.iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + changed.sort_by_key(|(handle, _)| handle.value()); + changed.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changed); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } CmdResult::CommitSolid { entity, solid, @@ -3572,6 +3626,19 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.open_mtext_editor(pos, handle, &initial, height); } + CmdResult::SuspendForMTextInput { + pos, + initial, + height, + } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_mtext_input = true; + self.pending_command_editor_text = None; + self.open_mtext_editor(pos, None, &initial, height); + } CmdResult::OpenTextEditor { pos, handle, diff --git a/src/app/mod.rs b/src/app/mod.rs index 3830c21dc..927a40bf6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -764,6 +764,10 @@ pub(super) struct OpenCADStudio { ctrl_down: bool, /// Open in-place MText editor (toolbar + text area + live preview), if any. mtext_editor: Option, + /// The current rich-text editor belongs to a suspended drawing command and + /// must return text instead of creating a standalone MText entity. + command_mtext_input: bool, + pending_command_editor_text: Option, /// Open in-place single-line TEXT editor (plain text-entry box), if any. text_inline: Option, /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas @@ -3252,6 +3256,8 @@ impl OpenCADStudio { ctrl_down: false, cont_anchor: None, mtext_editor: None, + command_mtext_input: false, + pending_command_editor_text: None, text_inline: None, snap_override_popup: None, axis_lock_dir: None, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f4566d813..f5dae78ee 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -1209,6 +1209,12 @@ impl super::OpenCADStudio { pub(super) fn mtext_commit(&mut self) -> bool { let i = self.active_tab; let Some(ed) = self.mtext_editor.take() else { return false }; + if self.command_mtext_input { + self.pending_command_editor_text = Some(ed.build_mtext().value); + self.command_mtext_input = false; + self.refresh_properties(); + return true; + } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); let annotative = ed.editing.is_none() @@ -1302,6 +1308,10 @@ impl super::OpenCADStudio { /// then updated in place on later applies, so repeated Apply never leaves /// duplicate entities behind. pub(super) fn mtext_apply(&mut self) { + if self.command_mtext_input { + self.rebuild_mtext_preview(); + return; + } let i = self.active_tab; let (mut mt, editing, body_empty) = match self.mtext_editor.as_ref() { Some(ed) => ( @@ -1398,6 +1408,8 @@ impl super::OpenCADStudio { /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; + self.command_mtext_input = false; + self.pending_command_editor_text = None; self.reset_modal_geometry(); } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8615be0e8..10a95940e 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -492,6 +492,13 @@ impl OpenCADStudio { self.reset_modal_geometry(); let i = self.active_tab; if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { + if committed { + if let Some(value) = self.pending_command_editor_text.take() { + cmd.on_editor_text(value); + } + } else { + self.pending_command_editor_text = None; + } let res = cmd.on_editor_closed(committed); self.tabs[i].active_cmd = Some(cmd); self.apply_cmd_result(res) diff --git a/src/command.rs b/src/command.rs index b8dbdb6fc..22440bcaf 100644 --- a/src/command.rs +++ b/src/command.rs @@ -169,6 +169,34 @@ pub struct SelectionEntity { pub surface_area: Option, } +/// One source point used by an associative dimension. A missing marker asks +/// the scene to infer the closest stable sub-entity; an explicit marker keeps +/// segment-level references intact. +#[derive(Clone, Copy, Debug)] +pub struct DimensionAssociationSource { + pub handle: Handle, + pub marker: Option, + pub parameter: f64, +} + +impl DimensionAssociationSource { + pub const fn inferred(handle: Handle) -> Self { + Self { + handle, + marker: None, + parameter: 0.0, + } + } + + pub const fn explicit(handle: Handle, marker: i32, parameter: f64) -> Self { + Self { + handle, + marker: Some(marker), + parameter, + } + } +} + #[derive(Clone)] pub enum AreaPreviewSource { Handles(Vec), @@ -1217,6 +1245,12 @@ pub enum CmdResult { entity: EntityType, sources: Vec, }, + /// Commit a dimension whose definition points carry explicit sub-entity + /// references. `None` leaves that definition point independent. + CommitAssociativeDimensionDetailed { + entity: EntityType, + sources: Vec>, + }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. @@ -1396,6 +1430,13 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Temporarily suspend the active command and collect formatted text in the + /// rich editor without creating a standalone MText entity. + SuspendForMTextInput { + pos: DVec3, + initial: String, + height: f64, + }, /// Open the in-place single-line TEXT editor (a plain text-entry box, no /// formatting toolbar). `handle` is `Some` when editing an existing Text, /// `None` when creating a new one at `pos`. @@ -1777,6 +1818,12 @@ pub trait CadCommand: Send { false } + /// Allow typed coordinates while object picking is active. Commands whose + /// first prompt accepts either an object or a point opt in explicitly. + fn entity_pick_accepts_points(&self) -> bool { + false + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally @@ -1805,6 +1852,10 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Supplies formatted text collected by `SuspendForMTextInput` before the + /// command is resumed through `on_editor_closed`. + fn on_editor_text(&mut self, _value: String) {} + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index d6e9edf97..4b7dc3fb9 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -2,7 +2,9 @@ use acadrust::entities::{Dimension, DimensionAngular2Ln, DimensionAngular3Pt}; use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationSource, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -26,13 +28,13 @@ enum Step { CircleSecondRay { vertex: DVec3, first: DVec3, - radius: f64, source: Handle, }, SecondLine { first_start: DVec3, first_end: DVec3, first_source: Handle, + first_refs: [SourceLocator; 2], }, ArcPoint3 { vertex: DVec3, @@ -47,16 +49,44 @@ enum Step { }, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct SourceLocator { + marker: Option, + parameter: f64, +} + +impl SourceLocator { + const INFERRED: Self = Self { marker: None, parameter: 0.0 }; + + const fn explicit(marker: i32, parameter: f64) -> Self { + Self { marker: Some(marker), parameter } + } + + fn bind(self, handle: Handle) -> DimensionAssociationSource { + match self.marker { + Some(marker) => DimensionAssociationSource::explicit(handle, marker, self.parameter), + None => DimensionAssociationSource::inferred(handle), + } + } +} + enum PickedCurve { - Line(DVec3, DVec3), + Line { + start: DVec3, + end: DVec3, + refs: [SourceLocator; 2], + normal: Option, + }, Arc { center: DVec3, first: DVec3, second: DVec3, + refs: [SourceLocator; 3], + normal: DVec3, }, Circle { center: DVec3, - radius: f64, + normal: DVec3, }, } @@ -65,12 +95,15 @@ pub struct AngularDimensionCommand { plane: WorkingPlane, selecting_object: bool, picked_entity: Option, - source_handles: Vec, + source_refs: Vec>, text_override: Option, awaiting_text: bool, mtext_override: bool, text_angle: Option, awaiting_angle: bool, + angle_origin: Option, + awaiting_quadrant: bool, + quadrant_lock: Option<(f64, f64)>, } impl AngularDimensionCommand { @@ -78,14 +111,17 @@ impl AngularDimensionCommand { Self { step: Step::Vertex, plane: WorkingPlane::default(), - selecting_object: false, + selecting_object: true, picked_entity: None, - source_handles: Vec::new(), + source_refs: Vec::new(), text_override: None, awaiting_text: false, mtext_override: false, text_angle: None, awaiting_angle: false, + angle_origin: None, + awaiting_quadrant: false, + quadrant_lock: None, } } @@ -99,12 +135,23 @@ impl AngularDimensionCommand { let vertex = self.plane.to_local(vertex); let first = self.plane.to_local(first); let second = self.plane.to_local(second); - let arc_point = self.plane.to_local(arc_point); + let picked_text = self.plane.to_local(arc_point); + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame(vertex, first, vertex, second, arc_point).is_none() { + return CmdResult::NeedPoint; + } let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); dim.definition_point = v3(arc_point); dim.base.definition_point = dim.definition_point; dim.base.text_middle_point = dim.definition_point; dim.base.insertion_point = dim.definition_point; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } dim.base.actual_measurement = dim.measurement_degrees(); crate::entities::dimension::set_dimension_text_override( &mut dim.base, @@ -128,7 +175,28 @@ impl AngularDimensionCommand { let first_end = self.plane.to_local(first_end); let second_start = self.plane.to_local(second_start); let second_end = self.plane.to_local(second_end); - let arc_point = self.plane.to_local(arc_point); + let picked_text = self.plane.to_local(arc_point); + let Some((vertex, _, _)) = two_line_frame( + first_start, + first_end, + second_start, + second_end, + picked_text, + ) else { + return CmdResult::NeedPoint; + }; + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + ) + .is_none() + { + return CmdResult::NeedPoint; + } let mut dim = DimensionAngular2Ln::default(); dim.first_point = v3(first_start); dim.second_point = v3(first_end); @@ -138,6 +206,13 @@ impl AngularDimensionCommand { dim.base.definition_point = dim.dimension_arc; dim.base.text_middle_point = dim.dimension_arc; dim.base.insertion_point = dim.dimension_arc; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } dim.base.actual_measurement = dim.measurement_degrees(); crate::entities::dimension::set_dimension_text_override( &mut dim.base, @@ -151,12 +226,12 @@ impl AngularDimensionCommand { fn commit(&self, dimension: Dimension) -> CmdResult { let entity = self.plane.place_entity(EntityType::Dimension(dimension)); - if self.source_handles.is_empty() { + if self.source_refs.iter().all(Option::is_none) { CmdResult::CommitAndExit(entity) } else { - CmdResult::CommitAssociativeDimension { + CmdResult::CommitAssociativeDimensionDetailed { entity, - sources: self.source_handles.clone(), + sources: self.source_refs.clone(), } } } @@ -164,6 +239,81 @@ impl AngularDimensionCommand { fn placement_step(&self) -> bool { matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::ArcPoint3 { vertex, first, second } => { + let radius = vertex.distance(first).max(vertex.distance(second)).max(1.0); + let first_angle = self.plane.angle(vertex, first).unwrap_or(0.0); + let second_angle = self.plane.angle(vertex, second).unwrap_or(first_angle); + let sweep = (second_angle - first_angle).rem_euclid(std::f64::consts::TAU); + let angle = first_angle + sweep * 0.5; + self.plane.to_world( + self.plane.to_local(vertex) + + DVec3::new(angle.cos() * radius, angle.sin() * radius, 0.0), + ) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + two_line_frame( + first_start, + first_end, + second_start, + second_end, + (first_start + second_start) * 0.5, + ) + .map(|(vertex, start, end)| { + self.plane.to_world(vertex + DVec3::new( + ((start + end) * 0.5).cos(), + ((start + end) * 0.5).sin(), + 0.0, + )) + }) + .unwrap_or_else(|| self.plane.to_world(first_start)) + } + _ => self.plane.origin, + } + } + + fn set_quadrant_from_point(&mut self, point: DVec3) -> bool { + let point = self.plane.to_local(point); + let frame = match self.step { + Step::ArcPoint3 { vertex, first, second } => two_line_frame( + self.plane.to_local(vertex), + self.plane.to_local(first), + self.plane.to_local(vertex), + self.plane.to_local(second), + point, + ), + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => two_line_frame( + self.plane.to_local(first_start), + self.plane.to_local(first_end), + self.plane.to_local(second_start), + self.plane.to_local(second_end), + point, + ), + _ => None, + }; + if let Some((_, start, end)) = frame { + self.quadrant_lock = Some((start, end)); + true + } else { + false + } + } } impl CadCommand for AngularDimensionCommand { @@ -176,6 +326,9 @@ impl CadCommand for AngularDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_quadrant { + return t!("DIMANGULAR Specify quadrant:").into_owned(); + } if self.awaiting_text { return if self.mtext_override { t!("DIMANGULAR Enter formatted dimension text (blank = measured value):") @@ -185,17 +338,24 @@ impl CadCommand for AngularDimensionCommand { }; } if self.awaiting_angle { - return t!("DIMANGULAR Specify text angle (degrees):").into_owned(); + return if self.angle_origin.is_some() { + t!("DIMANGULAR Specify second point for text angle:").into_owned() + } else { + t!("DIMANGULAR Specify text angle or first point:").into_owned() + }; } if self.selecting_object { return match self.step { Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), - _ => t!("DIMANGULAR Select arc, circle, line, or polyline arc:").into_owned(), + _ => t!( + "DIMANGULAR Select arc, circle, line, or specify an angle vertex:" + ) + .into_owned(), }; } match self.step { Step::Vertex => t!( - "DIMANGULAR Specify angle vertex or press Enter to select an object:" + "DIMANGULAR Specify angle vertex:" ) .into_owned(), Step::FirstRay(_) => { @@ -213,6 +373,29 @@ impl CadCommand for AngularDimensionCommand { } fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.awaiting_quadrant { + if self.set_quadrant_from_point(point) { + self.awaiting_quadrant = false; + } + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + if let Some(origin) = self.angle_origin { + if let Some(angle) = self.plane.angle(origin, point) { + self.text_angle = Some(angle); + self.awaiting_angle = false; + self.angle_origin = None; + } + } else { + self.angle_origin = Some(point); + } + return CmdResult::NeedPoint; + } + if self.selecting_object && matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + return CmdResult::NeedPoint; + } match self.step { Step::Vertex => { self.step = Step::FirstRay(point); @@ -229,15 +412,26 @@ impl CadCommand for AngularDimensionCommand { if point.distance_squared(vertex) <= 1.0e-24 { return CmdResult::NeedPoint; } + let first_local = self.plane.vector_to_local(first - vertex); + let second_local = self.plane.vector_to_local(point - vertex); + if (first_local.x * second_local.y - first_local.y * second_local.x).abs() + <= 1.0e-12 * first_local.length().max(second_local.length()).max(1.0) + { + return CmdResult::NeedPoint; + } self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } - Step::CircleSecondRay { vertex, first, radius, source } => { - let Some(second) = project_to_radius(vertex, point, radius) else { + Step::CircleSecondRay { vertex, first, source } => { + if point.distance_squared(vertex) <= 1.0e-24 { return CmdResult::NeedPoint; - }; - self.source_handles = vec![source, source, source]; - self.step = Step::ArcPoint3 { vertex, first, second }; + } + self.source_refs = vec![ + Some(DimensionAssociationSource::inferred(source)), + Some(DimensionAssociationSource::inferred(source)), + None, + ]; + self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } Step::SecondLine { .. } => CmdResult::NeedPoint, @@ -260,16 +454,21 @@ impl CadCommand for AngularDimensionCommand { } fn on_enter(&mut self) -> CmdResult { + if self.awaiting_quadrant { + self.awaiting_quadrant = false; + return CmdResult::NeedPoint; + } if self.awaiting_text { self.awaiting_text = false; return CmdResult::NeedPoint; } if self.awaiting_angle { self.awaiting_angle = false; + self.angle_origin = None; return CmdResult::NeedPoint; } if matches!(self.step, Step::Vertex) { - self.selecting_object = true; + self.selecting_object = !self.selecting_object; return CmdResult::NeedPoint; } CmdResult::Cancel @@ -287,8 +486,24 @@ impl CadCommand for AngularDimensionCommand { self.awaiting_text } + fn options(&self) -> Vec { + if self.placement_step() && !self.awaiting_text && !self.awaiting_angle && !self.awaiting_quadrant { + vec![ + CmdOption::new("Mtext", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + CmdOption::new("Quadrant", "QUADRANT"), + ] + } else { + Vec::new() + } + } + fn point_step_accepts_keywords(&self) -> bool { - self.placement_step() && !self.awaiting_text && !self.awaiting_angle + self.placement_step() + && !self.awaiting_text + && !self.awaiting_angle + && !self.awaiting_quadrant } fn on_text_input(&mut self, text: &str) -> Option { @@ -309,6 +524,7 @@ impl CadCommand for AngularDimensionCommand { crate::entities::common::parse_typed_angle(text.trim()) }; self.awaiting_angle = false; + self.angle_origin = None; return Some(CmdResult::NeedPoint); } if !self.placement_step() { @@ -322,22 +538,46 @@ impl CadCommand for AngularDimensionCommand { } "M" | "MTEXT" => { self.mtext_override = true; - self.awaiting_text = true; - Some(CmdResult::NeedPoint) + Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }) } "A" | "ANGLE" => { self.awaiting_angle = true; + self.angle_origin = None; + Some(CmdResult::NeedPoint) + } + "Q" | "QUADRANT" => { + self.awaiting_quadrant = true; Some(CmdResult::NeedPoint) } - "Q" | "QUADRANT" => Some(CmdResult::NeedPoint), _ => None, } } + fn on_editor_text(&mut self, value: String) { + let value = value.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + } + + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::NeedPoint + } + fn needs_entity_pick(&self) -> bool { self.selecting_object } + fn entity_pick_accepts_points(&self) -> bool { + self.selecting_object && matches!(self.step, Step::Vertex) + } + fn entity_pick_highlights_hover(&self) -> bool { true } @@ -352,6 +592,10 @@ impl CadCommand for AngularDimensionCommand { fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { if handle.is_null() { + if matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + } return CmdResult::NeedPoint; } let Some(entity) = self.picked_entity.take() else { @@ -360,13 +604,42 @@ impl CadCommand for AngularDimensionCommand { let Some(curve) = picked_curve(&entity, point) else { return CmdResult::NeedPoint; }; + if !curve_is_coplanar(&curve, self.plane) { + return CmdResult::NeedPoint; + } match (&self.step, curve) { ( - Step::SecondLine { first_start, first_end, first_source }, - PickedCurve::Line(second_start, second_end), - ) if *first_source != handle => { - self.source_handles = vec![*first_source, *first_source, handle, handle]; + Step::SecondLine { + first_start, + first_end, + first_source, + first_refs, + }, + PickedCurve::Line { + start: second_start, + end: second_end, + refs: second_refs, + .. + }, + ) => { + if (*first_source == handle && *first_refs == second_refs) + || lines_parallel_in_plane( + self.plane, + *first_start, + *first_end, + second_start, + second_end, + ) + { + return CmdResult::NeedPoint; + } + self.source_refs = vec![ + Some(first_refs[0].bind(*first_source)), + Some(first_refs[1].bind(*first_source)), + Some(second_refs[0].bind(handle)), + Some(second_refs[1].bind(handle)), + ]; self.selecting_object = false; self.step = Step::ArcPoint2 { first_start: *first_start, @@ -376,29 +649,49 @@ impl CadCommand for AngularDimensionCommand { }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Line(first_start, first_end)) => { + ( + Step::Vertex, + PickedCurve::Line { + start: first_start, + end: first_end, + refs, + .. + }, + ) => { self.step = Step::SecondLine { first_start, first_end, first_source: handle, + first_refs: refs, }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Arc { center, first, second }) => { - self.source_handles = vec![handle, handle, handle]; + ( + Step::Vertex, + PickedCurve::Arc { + center, + first, + second, + refs, + .. + }, + ) => { + self.source_refs = refs + .into_iter() + .map(|reference| Some(reference.bind(handle))) + .collect(); self.selecting_object = false; self.step = Step::ArcPoint3 { vertex: center, first, second }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Circle { center, radius }) => { - let Some(first) = project_to_radius(center, point, radius) else { + (Step::Vertex, PickedCurve::Circle { center, .. }) => { + if point.distance_squared(center) <= 1.0e-24 { return CmdResult::NeedPoint; - }; + } self.selecting_object = false; self.step = Step::CircleSecondRay { vertex: center, - first, - radius, + first: point, source: handle, }; CmdResult::NeedPoint @@ -408,29 +701,63 @@ impl CadCommand for AngularDimensionCommand { } fn on_mouse_move(&mut self, point: DVec3) -> Option { + if self.awaiting_angle { + return self + .angle_origin + .map(|origin| preview_wire(vec![origin, point])); + } let points = match self.step { Step::Vertex | Step::SecondLine { .. } => return None, Step::FirstRay(vertex) => vec![vertex, point], Step::SecondRay { vertex, first } => vec![vertex, first, nan(), vertex, point], - Step::CircleSecondRay { vertex, first, radius, .. } => { - let second = project_to_radius(vertex, point, radius).unwrap_or(point); - vec![vertex, first, nan(), vertex, second] + Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first, nan(), vertex, point] } Step::ArcPoint3 { vertex, first, second } => { + let vertex = self.plane.to_local(vertex); + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let point = locked_arc_point( + vertex, + self.plane.to_local(point), + self.quadrant_lock, + ); angular_preview(vertex, first, second, point) + .into_iter() + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() } Step::ArcPoint2 { first_start, first_end, second_start, second_end, - } => two_line_preview( - first_start, - first_end, - second_start, - second_end, - point, - ), + } => { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + let point = self.plane.to_local(point); + let point = two_line_frame( + first_start, + first_end, + second_start, + second_end, + point, + ) + .map(|(vertex, _, _)| locked_arc_point(vertex, point, self.quadrant_lock)) + .unwrap_or(point); + two_line_preview( + first_start, + first_end, + second_start, + second_end, + point, + ) + .into_iter() + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() + } }; Some(preview_wire(points)) } @@ -438,24 +765,43 @@ impl CadCommand for AngularDimensionCommand { fn picked_curve(entity: &EntityType, click: DVec3) -> Option { let point = |value: Vector3| DVec3::new(value.x, value.y, value.z); + let normal = |value: Vector3| DVec3::new(value.x, value.y, value.z); match entity { - EntityType::Line(line) => Some(PickedCurve::Line(point(line.start), point(line.end))), + EntityType::Line(line) => { + let start = point(line.start); + let end = point(line.end); + (start.distance_squared(end) > 1.0e-24).then_some(PickedCurve::Line { + start, + end, + refs: [SourceLocator::INFERRED; 2], + normal: None, + }) + } EntityType::Arc(arc) => Some(PickedCurve::Arc { center: point(arc.center_wcs()), first: point(arc.start_point_wcs()), second: point(arc.end_point_wcs()), + refs: [SourceLocator::INFERRED; 3], + normal: normal(arc.normal), }), EntityType::Circle(circle) => Some(PickedCurve::Circle { center: point(circle.center_wcs()), - radius: circle.radius, + normal: normal(circle.normal), }), EntityType::LwPolyline(polyline) => { let click = crate::scene::view::transform::wcs_point_to_ocs( (click.x, click.y, click.z), (polyline.normal.x, polyline.normal.y, polyline.normal.z), ); + let points: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = polyline.vertices.iter().map(|vertex| vertex.bulge).collect(); let segment = nearest_segment_index( - polyline.vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + &points, + &bulges, polyline.is_closed, [click.0, click.1], )?; @@ -467,17 +813,24 @@ fn picked_curve(entity: &EntityType, click: DVec3) -> Option { first.bulge, polyline.elevation, polyline.normal, + segment, + polyline.vertices.len(), ) } EntityType::Polyline2D(polyline) => { - let vertices = crate::entities::polyline::drawn_vertices2d(polyline) - .unwrap_or_else(|| polyline.vertices.clone()); + let vertices = &polyline.vertices; let click = crate::scene::view::transform::wcs_point_to_ocs( (click.x, click.y, click.z), (polyline.normal.x, polyline.normal.y, polyline.normal.z), ); + let points: Vec<_> = vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = vertices.iter().map(|vertex| vertex.bulge).collect(); let segment = nearest_segment_index( - vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + &points, + &bulges, polyline.is_closed(), [click.0, click.1], )?; @@ -489,27 +842,86 @@ fn picked_curve(entity: &EntityType, click: DVec3) -> Option { first.bulge, polyline.elevation, polyline.normal, + segment, + vertices.len(), ) } _ => None, } } -fn nearest_segment_index(points: Vec<[f64; 2]>, closed: bool, click: [f64; 2]) -> Option { +fn nearest_segment_index( + points: &[[f64; 2]], + bulges: &[f64], + closed: bool, + click: [f64; 2], +) -> Option { if points.len() < 2 { return None; } let count = if closed { points.len() } else { points.len() - 1 }; (0..count).min_by(|first, second| { - segment_distance_squared(points[*first], points[(*first + 1) % points.len()], click) - .total_cmp(&segment_distance_squared( + polyline_segment_distance_squared( + points[*first], + points[(*first + 1) % points.len()], + bulges.get(*first).copied().unwrap_or(0.0), + click, + ) + .total_cmp(&polyline_segment_distance_squared( points[*second], points[(*second + 1) % points.len()], + bulges.get(*second).copied().unwrap_or(0.0), click, )) }) } +fn bulge_center(first: [f64; 2], second: [f64; 2], bulge: f64) -> Option<[f64; 2]> { + if bulge.abs() <= 1.0e-12 { + return None; + } + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let chord = dx.hypot(dy); + if chord <= 1.0e-12 { + return None; + } + let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + let offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); + Some([ + midpoint[0] - dy / chord * offset, + midpoint[1] + dx / chord * offset, + ]) +} + +fn polyline_segment_distance_squared( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + point: [f64; 2], +) -> f64 { + let Some(center) = bulge_center(first, second, bulge) else { + return segment_distance_squared(first, second, point); + }; + let radius = (first[0] - center[0]).hypot(first[1] - center[1]); + let start = (first[1] - center[1]).atan2(first[0] - center[0]); + let target = (point[1] - center[1]).atan2(point[0] - center[0]); + let sweep = 4.0 * bulge.atan(); + let into = if sweep >= 0.0 { + (target - start).rem_euclid(std::f64::consts::TAU) + } else { + (start - target).rem_euclid(std::f64::consts::TAU) + }; + if into <= sweep.abs() + 1.0e-12 { + let radial = (point[0] - center[0]).hypot(point[1] - center[1]); + (radial - radius).powi(2) + } else { + ((point[0] - first[0]).powi(2) + (point[1] - first[1]).powi(2)).min( + (point[0] - second[0]).powi(2) + (point[1] - second[1]).powi(2), + ) + } +} + fn segment_distance_squared(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 { let dx = second[0] - first[0]; let dy = second[1] - first[1]; @@ -530,6 +942,8 @@ fn polyline_segment( bulge: f64, elevation: f64, normal: Vector3, + segment: usize, + vertex_count: usize, ) -> Option { let to_world = |point: [f64; 2]| { let value = crate::scene::view::transform::ocs_point_to_wcs( @@ -540,31 +954,95 @@ fn polyline_segment( }; let first_world = to_world(first); let second_world = to_world(second); - if bulge.abs() <= 1.0e-12 { - return Some(PickedCurve::Line(first_world, second_world)); - } - let dx = second[0] - first[0]; - let dy = second[1] - first[1]; - let chord = (dx * dx + dy * dy).sqrt(); - if chord <= 1.0e-12 { + if first_world.distance_squared(second_world) <= 1.0e-24 { return None; } - let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; - let center_offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); - let center = [ - midpoint[0] - dy / chord * center_offset, - midpoint[1] + dx / chord * center_offset, + let refs = [ + SourceLocator::explicit(segment as i32, 0.0), + SourceLocator::explicit(((segment + 1) % vertex_count) as i32, 0.0), ]; + let normal_world = DVec3::new(normal.x, normal.y, normal.z); + if bulge.abs() <= 1.0e-12 { + return Some(PickedCurve::Line { + start: first_world, + end: second_world, + refs, + normal: Some(normal_world), + }); + } + let center = bulge_center(first, second, bulge)?; Some(PickedCurve::Arc { center: to_world(center), first: first_world, second: second_world, + refs: [ + SourceLocator::explicit(-4, segment as f64), + refs[0], + refs[1], + ], + normal: normal_world, }) } -fn project_to_radius(center: DVec3, point: DVec3, radius: f64) -> Option { - let direction = (point - center).normalize_or_zero(); - (direction.length_squared() > 0.0).then_some(center + direction * radius) +fn curve_is_coplanar(curve: &PickedCurve, plane: WorkingPlane) -> bool { + let (points, normal): (Vec, Option) = match curve { + PickedCurve::Line { start, end, normal, .. } => (vec![*start, *end], *normal), + PickedCurve::Arc { center, first, second, normal, .. } => { + (vec![*center, *first, *second], Some(*normal)) + } + PickedCurve::Circle { center, normal } => (vec![*center], Some(*normal)), + }; + if let Some(normal) = normal { + let alignment = normal.normalize_or_zero().dot(plane.z).abs(); + if alignment < 1.0 - 1.0e-7 { + return false; + } + } + let local: Vec<_> = points.into_iter().map(|point| plane.to_local(point)).collect(); + let min_z = local.iter().map(|point| point.z).fold(f64::INFINITY, f64::min); + let max_z = local.iter().map(|point| point.z).fold(f64::NEG_INFINITY, f64::max); + let scale = local + .iter() + .map(|point| point.x.abs().max(point.y.abs()).max(1.0)) + .fold(1.0, f64::max); + max_z - min_z <= 1.0e-9 * scale +} + +fn lines_parallel_in_plane( + plane: WorkingPlane, + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, +) -> bool { + let first = plane.vector_to_local(first_end - first_start); + let second = plane.vector_to_local(second_end - second_start); + let denominator = first.x * second.y - first.y * second.x; + denominator.abs() <= 1.0e-12 * first.length().max(second.length()).max(1.0) +} + +fn locked_arc_point( + vertex: DVec3, + point: DVec3, + frame: Option<(f64, f64)>, +) -> DVec3 { + let Some((start, end)) = frame else { return point }; + let radius = ((point.x - vertex.x).powi(2) + (point.y - vertex.y).powi(2)).sqrt(); + if radius <= 1.0e-12 { + return point; + } + let angle = (start + end) * 0.5; + DVec3::new( + vertex.x + angle.cos() * radius, + vertex.y + angle.sin() * radius, + vertex.z, + ) +} + +fn point_angle_in_frame(vertex: DVec3, point: DVec3, frame: (f64, f64)) -> bool { + let angle = (point.y - vertex.y).atan2(point.x - vertex.x); + let sweep = frame.1 - frame.0; + (angle - frame.0).rem_euclid(std::f64::consts::TAU) <= sweep + 1.0e-9 } fn two_line_frame( diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 853d40092..4918550f0 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -6,6 +6,8 @@ use acadrust::objects::{ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; +use crate::command::DimensionAssociationSource; + use super::{ChangeKind, Scene}; fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { @@ -23,6 +25,62 @@ fn ocs_point(x: f64, y: f64, elevation: f64, normal: Vector3) -> Vector3 { Vector3::new(point.0, point.1, point.2) } +fn bulge_center_world( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + elevation: f64, + normal: Vector3, +) -> Option { + if bulge.abs() <= 1.0e-12 { + return None; + } + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let chord = dx.hypot(dy); + if chord <= 1.0e-12 { + return None; + } + let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + let offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); + Some(ocs_point( + midpoint[0] - dy / chord * offset, + midpoint[1] + dx / chord * offset, + elevation, + normal, + )) +} + +fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { + match entity { + EntityType::LwPolyline(polyline) => { + let count = polyline.vertices.len(); + let first = *polyline.vertices.get(segment)?; + let second = *polyline.vertices.get((segment + 1) % count)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + EntityType::Polyline2D(polyline) => { + let count = polyline.vertices.len(); + let first = polyline.vertices.get(segment)?; + let second = polyline.vertices.get((segment + 1) % count)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + _ => None, + } +} + fn source_points(entity: &EntityType) -> Vec { match entity { EntityType::Line(line) => vec![line.start, line.end], @@ -101,6 +159,10 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { let source = *reference.xrefs.first()?; let entity = scene.document.get_entity(source)?; + if reference.main_gs_marker == -4 { + let segment = reference.osnap_distance.round().max(0.0) as usize; + return polyline_arc_center(entity, segment); + } if reference.main_gs_marker == -3 { return match entity { EntityType::Circle(circle) => Some(circle.center_wcs()), @@ -167,6 +229,20 @@ impl Scene { &mut self, dimension: Handle, sources: Vec>, + ) { + self.attach_dimension_association_sources( + dimension, + sources + .into_iter() + .map(|source| source.map(DimensionAssociationSource::inferred)) + .collect(), + ); + } + + pub(crate) fn attach_dimension_association_sources( + &mut self, + dimension: Handle, + sources: Vec>, ) { let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) @@ -182,9 +258,14 @@ impl Scene { .enumerate() .map(|(index, point)| { let source = sources.get(index).copied().flatten()?; - let entity = self.document.get_entity(source)?; - source_reference(entity, *point) - .map(|(marker, parameter)| (source, marker, parameter)) + let (marker, parameter) = match source.marker { + Some(marker) => (marker, source.parameter), + None => { + let entity = self.document.get_entity(source.handle)?; + source_reference(entity, *point)? + } + }; + Some((source.handle, marker, parameter)) }) .collect(); if resolved.iter().all(Option::is_none) { @@ -229,7 +310,7 @@ impl Scene { .insert(association_handle, ObjectType::Associative(object)); let mut reactor_targets = vec![dimension]; - reactor_targets.extend(sources.into_iter().flatten()); + reactor_targets.extend(sources.into_iter().flatten().map(|source| source.handle)); reactor_targets.sort_by_key(|handle| handle.value()); reactor_targets.dedup(); for handle in reactor_targets {