//! Quick dimension creation from a selected set of drawing entities. use codec::entities::{ Dimension, DimensionDiameter, DimensionLinear, DimensionOrdinate, DimensionRadius, }; use codec::types::Vector3; use codec::{EntityType, Handle}; use glam::DVec3; use kernel::geom2d::{characteristic_points, intersect, Curve, SnapKind, Tolerance}; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, DimensionPreview, EntityTransform, SelectionEntity, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/qdim.svg")); pub fn tool() -> ToolDef { ToolDef { id: "QDIM", label: "Quick Dim", icon: ICON, event: ModuleEvent::Command("QDIM".to_string()), } } #[derive(Clone, Copy, PartialEq, Eq)] enum Step { Gathering, Place, Datum, Edit, Settings, } #[derive(Clone, Copy, PartialEq, Eq)] enum Mode { Continuous, Staggered, Baseline, Ordinate, Radius, Diameter, } #[derive(Clone, Copy, PartialEq, Eq)] enum SnapPriority { Endpoints, Intersections, } #[derive(Clone)] struct SourceCurve { handle: Handle, curve: Curve, } #[derive(Clone)] struct Candidate { point: DVec3, sources: Vec, } pub struct QdimCommand { step: Step, mode: Mode, snap_priority: SnapPriority, plane: WorkingPlane, selection: Vec, datum: Option, excluded: Vec, dim_spacing: f64, } impl QdimCommand { pub fn new(selection: Vec, dim_spacing: f64, snap_priority: u8) -> Self { let step = if selection.is_empty() { Step::Gathering } else { Step::Place }; Self { step, mode: Mode::Continuous, snap_priority: if snap_priority == 1 { SnapPriority::Intersections } else { SnapPriority::Endpoints }, plane: WorkingPlane::default(), selection, datum: None, excluded: Vec::new(), dim_spacing: dim_spacing.abs().max(1e-9), } } fn local_sources(&self) -> Vec { let transform = EntityTransform::Affine(self.plane.to_local_transform()); self.selection .iter() .filter_map(|selected| { let mut entity = selected.entity.clone(); crate::scene::view::dispatch::apply_transform(&mut entity, &transform); crate::entities::curve::entity_curve_xy(&entity).map(|curve| SourceCurve { handle: selected.handle, curve, }) }) .collect() } fn candidates(&self) -> Vec { let curves = self.local_sources(); let mut candidates = if self.snap_priority == SnapPriority::Intersections { let mut hits = Vec::new(); for first in 0..curves.len() { for second in first + 1..curves.len() { for crossing in intersect( &curves[first].curve, &curves[second].curve, Tolerance::default(), ) { hits.push(Candidate { point: DVec3::new(crossing.point[0], crossing.point[1], 0.0), // Intersections depend on two curves, but each // extension origin stores one source. sources: Vec::new(), }); } } } hits } else { Vec::new() }; if candidates.is_empty() { for source in &curves { for point in characteristic_points(&source.curve) .into_iter() .filter(|point| point.kind == SnapKind::Endpoint) { candidates.push(Candidate { point: DVec3::new(point.point[0], point.point[1], 0.0), sources: vec![DimensionAssociationSource::inferred(source.handle)], }); } } } for selected in &self.selection { if let EntityType::Dimension(dimension) = &selected.entity { for point in dimension_reference_points(dimension) { candidates.push(Candidate { point: self.plane.to_local(point), sources: vec![DimensionAssociationSource::inferred(selected.handle)], }); } } } let tolerance = candidate_tolerance(candidates.iter().map(|candidate| candidate.point)); let mut unique: Vec = Vec::new(); for candidate in candidates { if let Some(existing) = unique .iter_mut() .find(|existing| existing.point.distance(candidate.point) <= tolerance) { for source in candidate.sources { if !existing .sources .iter() .any(|current| current.handle == source.handle) { existing.sources.push(source); } } } else { unique.push(candidate); } } unique.retain(|candidate| { !self .excluded .iter() .any(|point| point.distance(candidate.point) <= tolerance) }); unique } fn radial_sources(&self) -> Vec<(Handle, Curve)> { self.local_sources() .into_iter() .filter(|source| matches!(source.curve, Curve::Circle(_) | Curve::Arc(_))) .map(|source| (source.handle, source.curve)) .collect() } fn orientation(&self, points: &[Candidate], place: DVec3) -> bool { let min_x = points .iter() .map(|point| point.point.x) .fold(f64::INFINITY, f64::min); let max_x = points .iter() .map(|point| point.point.x) .fold(f64::NEG_INFINITY, f64::max); let min_y = points .iter() .map(|point| point.point.y) .fold(f64::INFINITY, f64::min); let max_y = points .iter() .map(|point| point.point.y) .fold(f64::NEG_INFINITY, f64::max); let outside_y = (place.y - max_y).max(min_y - place.y).max(0.0); let outside_x = (place.x - max_x).max(min_x - place.x).max(0.0); if (outside_x - outside_y).abs() > 1e-9 { outside_y >= outside_x } else { max_x - min_x >= max_y - min_y } } fn build_dimensions(&self, place_world: DVec3) -> Vec<(EntityType, DimensionAssociationInput)> { let place = self.plane.to_local(place_world); if matches!(self.mode, Mode::Radius | Mode::Diameter) { return self.build_radial_dimensions(place); } let mut points = self.candidates(); if points.is_empty() { return Vec::new(); } let horizontal = self.orientation(&points, place); points.sort_by(|first, second| { if horizontal { first.point.x.total_cmp(&second.point.x) } else { first.point.y.total_cmp(&second.point.y) } }); let tolerance = candidate_tolerance(points.iter().map(|point| point.point)); points.dedup_by(|first, second| { let delta = if horizontal { first.point.x - second.point.x } else { first.point.y - second.point.y }; delta.abs() <= tolerance }); match self.mode { Mode::Continuous | Mode::Staggered => points .windows(2) .enumerate() .map(|(index, pair)| { let offset = if self.mode == Mode::Staggered { index as f64 * self.dim_spacing } else { 0.0 }; let direction = if horizontal { if place.y >= (pair[0].point.y + pair[1].point.y) * 0.5 { 1.0 } else { -1.0 } } else if place.x >= (pair[0].point.x + pair[1].point.x) * 0.5 { 1.0 } else { -1.0 }; let shifted = if horizontal { DVec3::new(place.x, place.y + direction * offset, 0.0) } else { DVec3::new(place.x + direction * offset, place.y, 0.0) }; self.linear_dimension(&pair[0], &pair[1], shifted, horizontal) }) .collect(), Mode::Baseline => { let datum = self.datum.unwrap_or(points[0].point); let datum_candidate = Candidate { point: datum, sources: points .iter() .find(|point| point.point.distance(datum) <= tolerance) .map(|point| point.sources.clone()) .unwrap_or_default(), }; points .iter() .filter(|point| point.point.distance(datum) > tolerance) .map(|point| self.linear_dimension(&datum_candidate, point, place, horizontal)) .collect() } Mode::Ordinate => points .iter() .map(|point| self.ordinate_dimension(point, place, horizontal)) .collect(), Mode::Radius | Mode::Diameter => Vec::new(), } } fn linear_dimension( &self, first: &Candidate, second: &Candidate, place: DVec3, horizontal: bool, ) -> (EntityType, DimensionAssociationInput) { let mut dimension = DimensionLinear::new(v3(first.point), v3(second.point)); dimension.rotation = if horizontal { 0.0 } else { std::f64::consts::FRAC_PI_2 }; let definition = if horizontal { DVec3::new(second.point.x, place.y, 0.0) } else { DVec3::new(place.x, second.point.y, 0.0) }; let text = if horizontal { DVec3::new((first.point.x + second.point.x) * 0.5, place.y, 0.0) } else { DVec3::new(place.x, (first.point.y + second.point.y) * 0.5, 0.0) }; dimension.definition_point = v3(definition); dimension.base.definition_point = v3(definition); dimension.base.text_middle_point = v3(text); dimension.base.insertion_point = v3(text); crate::entities::dimension::reset_automatic_text_position(&mut dimension.base); dimension.base.actual_measurement = dimension.measurement(); let association = DimensionAssociationInput::Explicit(vec![ first.sources.first().copied(), second.sources.first().copied(), ]); ( self.plane .place_entity(EntityType::Dimension(Box::new(Dimension::Linear(dimension)))), association, ) } fn ordinate_dimension( &self, point: &Candidate, place: DVec3, horizontal: bool, ) -> (EntityType, DimensionAssociationInput) { let datum = self.datum.unwrap_or(DVec3::ZERO); let leader = if horizontal { DVec3::new(point.point.x, place.y, 0.0) } else { DVec3::new(place.x, point.point.y, 0.0) }; let mut dimension = DimensionOrdinate::new(v3(point.point), v3(leader), horizontal); dimension.definition_point = v3(datum); dimension.base.definition_point = v3(datum); dimension.base.text_middle_point = v3(leader); dimension.base.insertion_point = v3(leader); dimension.refresh_measurement(); ( self.plane .place_entity(EntityType::Dimension(Box::new(Dimension::Ordinate(dimension)))), DimensionAssociationInput::Explicit(vec![point.sources.first().copied()]), ) } fn build_radial_dimensions( &self, place: DVec3, ) -> Vec<(EntityType, DimensionAssociationInput)> { self.radial_sources() .into_iter() .enumerate() .map(|(index, (handle, curve))| { let (center, radius) = match curve { Curve::Circle(circle) => (circle.centre, circle.radius), Curve::Arc(arc) => (arc.centre, arc.radius), _ => unreachable!(), }; let center = DVec3::new(center[0], center[1], 0.0); let mut direction = place - center; direction.z = 0.0; direction = if direction.length_squared() <= f64::EPSILON { DVec3::X } else { direction.normalize() }; let chord = center + direction * radius; let text = place + DVec3::new(0.0, index as f64 * self.dim_spacing, 0.0); let association = DimensionAssociationInput::Explicit(vec![Some( DimensionAssociationSource::inferred(handle), )]); let entity = if self.mode == Mode::Radius { let mut dimension = DimensionRadius::new(v3(center), v3(chord)); dimension.base.definition_point = v3(chord); dimension.base.text_middle_point = v3(text); dimension.base.insertion_point = v3(text); dimension.base.text_user_positioned = true; dimension.leader_length = chord.distance(text); dimension.base.actual_measurement = dimension.measurement(); EntityType::Dimension(Box::new(Dimension::Radius(dimension))) } else { let opposite = center - direction * radius; let mut dimension = DimensionDiameter::new(v3(chord), v3(opposite)); dimension.base.definition_point = v3(opposite); dimension.base.text_middle_point = v3(text); dimension.base.insertion_point = v3(text); dimension.base.text_user_positioned = true; dimension.leader_length = chord.distance(text); dimension.base.actual_measurement = dimension.measurement(); EntityType::Dimension(Box::new(Dimension::Diameter(dimension))) }; (self.plane.place_entity(entity), association) }) .collect() } fn remove_candidate(&mut self, point_world: DVec3) { let point = self.plane.to_local(point_world); let all = self.candidates(); let Some(nearest) = all.iter().min_by(|first, second| { first .point .distance(point) .total_cmp(&second.point.distance(point)) }) else { return; }; self.excluded.push(nearest.point); } fn preview_dimensions(&self, point: DVec3) -> Vec { self.build_dimensions(point) .into_iter() .enumerate() .flat_map(|(index, (entity, _))| preview_entity(index, &entity)) .collect() } } impl CadCommand for QdimCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } fn name(&self) -> &'static str { "QDIM" } fn prompt(&self) -> String { match self.step { Step::Gathering => { t!("QDIM Select geometry to dimension (Enter when done):").into_owned() } Step::Place => { t!("QDIM Specify dimension line position or choose an option:").into_owned() } Step::Datum => t!("QDIM Specify datum point:").into_owned(), Step::Edit => t!("QDIM Select a point to remove (Enter when done):").into_owned(), Step::Settings => { t!("QDIM Choose extension origin priority [Endpoints/Intersections]:").into_owned() } } } fn options(&self) -> Vec { match self.step { Step::Place => vec![ CmdOption::new("Continuous", "CONTINUOUS"), CmdOption::new("Staggered", "STAGGERED"), CmdOption::new("Baseline", "BASELINE"), CmdOption::new("Ordinate", "ORDINATE"), CmdOption::new("Radius", "RADIUS"), CmdOption::new("Diameter", "DIAMETER"), CmdOption::new("Datum point", "DATUM"), CmdOption::new("Edit", "EDIT"), CmdOption::new("Settings", "SETTINGS"), ], Step::Settings => vec![ CmdOption::new("Endpoints", "ENDPOINTS"), CmdOption::new("Intersections", "INTERSECTIONS"), ], _ => Vec::new(), } } fn is_selection_gathering(&self) -> bool { self.step == Step::Gathering } fn selection_forces_add(&self) -> bool { self.step == Step::Gathering } fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { CmdResult::NeedPoint } fn inject_selection_entities(&mut self, entities: Vec) { self.selection = entities; } fn wants_text_input(&self) -> bool { matches!(self.step, Step::Place | Step::Settings) } fn point_step_accepts_keywords(&self) -> bool { self.step == Step::Place } fn on_text_input(&mut self, text: &str) -> Option { let keyword = text.trim().to_ascii_uppercase(); if self.step == Step::Settings { match keyword.as_str() { "E" | "ENDPOINT" | "ENDPOINTS" => { self.snap_priority = SnapPriority::Endpoints; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(0)); } "I" | "INTERSECTION" | "INTERSECTIONS" => { self.snap_priority = SnapPriority::Intersections; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(1)); } _ => return Some(CmdResult::NeedPoint), } } match keyword.as_str() { "C" | "CONTINUOUS" => self.mode = Mode::Continuous, "S" | "STAGGERED" => self.mode = Mode::Staggered, "B" | "BASELINE" => self.mode = Mode::Baseline, "O" | "ORDINATE" => self.mode = Mode::Ordinate, "R" | "RADIUS" => self.mode = Mode::Radius, "DI" | "DIAMETER" => self.mode = Mode::Diameter, "DA" | "DATUM" | "DATUMPOINT" => { self.step = Step::Datum; return Some(CmdResult::NeedPoint); } "E" | "EDIT" => { self.step = Step::Edit; return Some(CmdResult::NeedPoint); } "SE" | "SETTINGS" => { self.step = Step::Settings; return Some(CmdResult::NeedPoint); } _ => return Some(CmdResult::NeedPoint), } Some(CmdResult::NeedPoint) } fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { Step::Gathering | Step::Settings => CmdResult::NeedPoint, Step::Datum => { self.datum = Some(self.plane.to_local(point)); self.step = Step::Place; CmdResult::NeedPoint } Step::Edit => { self.remove_candidate(point); CmdResult::NeedPoint } Step::Place => { let dimensions = self.build_dimensions(point); if dimensions.is_empty() { CmdResult::NeedPoint } else { CmdResult::CommitDimensionsAndExit(dimensions) } } } } fn on_enter(&mut self) -> CmdResult { match self.step { Step::Gathering if !self.selection.is_empty() => { self.step = Step::Place; CmdResult::NeedPoint } Step::Edit | Step::Datum | Step::Settings => { self.step = Step::Place; CmdResult::NeedPoint } _ => CmdResult::Cancel, } } fn on_preview_wires(&mut self, point: DVec3) -> Vec { if self.step == Step::Place { return self.preview_dimensions(point); } if self.step == Step::Edit { let size = self.dim_spacing * 0.12; return self .candidates() .into_iter() .enumerate() .flat_map(|(index, candidate)| { let x_start = self.plane.to_world(candidate.point - DVec3::X * size); let x_end = self.plane.to_world(candidate.point + DVec3::X * size); let y_start = self.plane.to_world(candidate.point - DVec3::Y * size); let y_end = self.plane.to_world(candidate.point + DVec3::Y * size); [ WireModel::solid_f64( format!("qdim_edit_{index}_x"), vec![x_start.to_array(), x_end.to_array()], WireModel::CYAN, false, ), WireModel::solid_f64( format!("qdim_edit_{index}_y"), vec![y_start.to_array(), y_end.to_array()], WireModel::CYAN, false, ), ] }) .collect(); } Vec::new() } fn dimension_preview(&self, cursor: DVec3) -> Option> { (self.step == Step::Place).then(|| { self.build_dimensions(cursor) .into_iter() .map(|(entity, _)| DimensionPreview::current_style(entity)) .collect() }) } } fn v3(point: DVec3) -> Vector3 { Vector3::new(point.x, point.y, point.z) } fn d3(point: Vector3) -> DVec3 { DVec3::new(point.x, point.y, point.z) } fn candidate_tolerance(points: impl Iterator) -> f64 { let magnitude = points .map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs())) .fold(1.0_f64, f64::max); Tolerance::default() .linear() .max(magnitude * f64::EPSILON * 64.0) } fn dimension_reference_points(dimension: &Dimension) -> Vec { match dimension { Dimension::Linear(value) => vec![d3(value.first_point), d3(value.second_point)], Dimension::Aligned(value) => vec![d3(value.first_point), d3(value.second_point)], Dimension::Radius(value) => vec![d3(value.angle_vertex), d3(value.definition_point)], Dimension::Diameter(value) => vec![d3(value.angle_vertex), d3(value.definition_point)], Dimension::Ordinate(value) => vec![d3(value.feature_location)], Dimension::Angular2Ln(value) => vec![ d3(value.first_point), d3(value.second_point), d3(value.angle_vertex), d3(value.definition_point), ], Dimension::Angular3Pt(value) => vec![ d3(value.angle_vertex), d3(value.first_point), d3(value.second_point), ], Dimension::Arc(value) => vec![ d3(value.center_point), d3(value.first_extension_point), d3(value.second_extension_point), ], Dimension::LargeRadial(value) => vec![d3(value.definition_point), d3(value.chord_point)], } } fn preview_entity(index: usize, entity: &EntityType) -> Vec { let EntityType::Dimension(dimension) = entity else { return Vec::new(); }; let mut lines: Vec> = Vec::new(); match &**dimension { Dimension::Linear(value) => { let first = d3(value.first_point); let second = d3(value.second_point); let definition = d3(value.definition_point); let horizontal = value.rotation.cos().abs() >= value.rotation.sin().abs(); let first_line = if horizontal { DVec3::new(first.x, definition.y, definition.z) } else { DVec3::new(definition.x, first.y, definition.z) }; let second_line = if horizontal { DVec3::new(second.x, definition.y, definition.z) } else { DVec3::new(definition.x, second.y, definition.z) }; lines.push(vec![first.to_array(), first_line.to_array()]); lines.push(vec![second.to_array(), second_line.to_array()]); lines.push(vec![first_line.to_array(), second_line.to_array()]); } Dimension::Ordinate(value) => lines.push(vec![ d3(value.feature_location).to_array(), d3(value.leader_endpoint).to_array(), ]), Dimension::Radius(value) => lines.push(vec![ d3(value.angle_vertex).to_array(), d3(value.definition_point).to_array(), d3(value.base.text_middle_point).to_array(), ]), Dimension::Diameter(value) => lines.push(vec![ d3(value.definition_point).to_array(), d3(value.angle_vertex).to_array(), d3(value.base.text_middle_point).to_array(), ]), _ => {} } lines .into_iter() .enumerate() .map(|(part, points)| { WireModel::solid_f64( format!("qdim_preview_{index}_{part}"), points, WireModel::CYAN, false, ) }) .collect() } inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] });