From a5d2ff741cd1d53e48cfef11b5db1bdcc8688185 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:19:23 +0300 Subject: [PATCH] Complete QDIM dimension workflows --- src/app/command_driver.rs | 126 ++++++++++ src/app/commands/dim.rs | 166 ++----------- src/app/mod.rs | 3 + src/app/settings.rs | 4 + src/app/update/file.rs | 2 + src/command.rs | 6 + src/modules/annotate/qdim.rs | 463 +++++++++++++++++++++++++++-------- 7 files changed, 524 insertions(+), 246 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 09eb09718..6a29d075c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1400,6 +1400,132 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CommitDimensionsAndExit(dimensions) => { + let association_mode = self.tabs[i] + .scene + .document + .header + .dimension_associativity; + let made = dimensions.len(); + let pending = if association_mode == 0 { + let mut pieces = Vec::new(); + for (mut entity, _) in dimensions { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); + } + crate::scene::view::dispatch::apply_color( + &mut entity, + self.ribbon.active_color, + ); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + pieces.extend( + crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ), + ); + } + let delta_safe = pieces + .iter() + .all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = dimensions + .iter() + .all(|(entity, _)| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe); + for (entity, association) in dimensions { + let Some(handle) = self.commit_entity_handle(entity) else { + continue; + }; + if association_mode != 2 { + continue; + } + let sources = match association { + crate::command::DimensionAssociationInput::Infer(source) => { + source.map_or_else( + || { + self.tabs[i] + .scene + .infer_dimension_sources(handle) + .into_iter() + .map(|source| { + source.map( + crate::command::DimensionAssociationSource::inferred, + ) + }) + .collect() + }, + |source| { + vec![Some( + crate::command::DimensionAssociationSource::inferred( + source, + ), + )] + }, + ) + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + sources + } + }; + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; + changes.extend(sources.into_iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + pending + }; + self.tabs[i].dirty = made > 0; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::SetQuickDimensionSnapPriority(priority) => { + self.quick_dimension_snap_priority = priority.min(1); + self.persist_settings_if_changed(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()); + if let Some(prompt) = prompt { + self.command_line.push_info(&prompt); + } + } CmdResult::CommitSolid { entity, solid, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 22ee0d96e..b80fb226a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -682,102 +682,34 @@ impl OpenCADStudio { "QDIM" => { use crate::modules::annotate::qdim::QdimCommand; - let cmd = QdimCommand::new(); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - - // QDIM second stage: the front-end relaunched with the picked - // dimension-line point and the gathered geometry now selected. Build - // a continuous chain of linear dimensions across the endpoints. - cmd if cmd.starts_with("QDIM_PLACE ") => { - let nums: Vec = cmd - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse().ok()) - .collect(); - if nums.len() < 3 { - return Some(Task::none()); - } - let plane = if self.tabs[i].editing_model_space() { - self.tabs[i].ucs_xform().working_plane() - } else { - crate::command::WorkingPlane::default() - }; - let place = plane.to_local(glam::DVec3::new(nums[0], nums[1], nums[2])); - let handles: Vec = self.tabs[i] + let document = &self.tabs[i].scene.document; + let dim_spacing = document + .dim_styles + .iter() + .find(|style| { + style + .name + .eq_ignore_ascii_case(&document.header.current_dimstyle_name) + }) + .map(|style| style.dimdli) + .unwrap_or(1.5); + let selection = self.tabs[i] .scene .selected_entities() - .iter() - .map(|(h, _)| *h) + .into_iter() + .map(|(handle, entity)| crate::command::SelectionEntity { + handle, + entity: entity.clone(), + surface_area: None, + }) .collect(); - let mut pts: Vec = Vec::new(); - for h in &handles { - if let Some(e) = self.tabs[i].scene.document.get_entity(*h) { - qdim_collect_points(e, &mut pts); - } - } - for point in &mut pts { - *point = plane.to_local(*point); - } - if pts.len() < 2 { - self.command_line - .push_error(crate::t!("QDIM: no dimensionable endpoints in the selection.").as_ref()); - return Some(Task::none()); - } - // Choose the dimension axis from the points' spread: a wider X - // span dimensions horizontally (ordered by X), else vertically. - let (minx, maxx) = pts - .iter() - .fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x))); - let (miny, maxy) = pts - .iter() - .fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y))); - let horizontal = (maxx - minx) >= (maxy - miny); - if horizontal { - pts.sort_by(|a, b| a.x.total_cmp(&b.x)); - pts.dedup_by(|a, b| (a.x - b.x).abs() < 1e-4); - } else { - pts.sort_by(|a, b| a.y.total_cmp(&b.y)); - pts.dedup_by(|a, b| (a.y - b.y).abs() < 1e-4); - } - if pts.len() < 2 { - self.command_line - .push_error(crate::t!("QDIM: endpoints collapse to a single position.").as_ref()); - return Some(Task::none()); - } - self.push_undo_snapshot(i, "QDIM"); - let v = |p: glam::DVec3| { - acadrust::types::Vector3::new(p.x, p.y, p.z) - }; - let mut made = 0usize; - for w in pts.windows(2) { - let (p1, p2) = (w[0], w[1]); - let mut dim = acadrust::entities::DimensionLinear::new(v(p1), v(p2)); - dim.rotation = if horizontal { - 0.0 - } else { - std::f64::consts::FRAC_PI_2 - }; - // Dim line passes through the picked perpendicular position. - let def = if horizontal { - glam::DVec3::new((p1.x + p2.x) * 0.5, place.y, 0.0) - } else { - glam::DVec3::new(place.x, (p1.y + p2.y) * 0.5, 0.0) - }; - dim.definition_point = v(def); - dim.base.definition_point = v(def); - dim.base.actual_measurement = dim.measurement(); - let entity = acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Linear(dim), - ); - self.commit_entity(plane.place_entity(entity)); - made += 1; - } - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); - return Some(Task::none()); + let cmd = QdimCommand::new( + selection, + dim_spacing, + self.quick_dimension_snap_priority, + ); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); } "DIMEDIT" => { @@ -1442,54 +1374,6 @@ fn find_last_linear_dim( result } -/// Collect candidate dimension endpoints from an entity for QDIM — line ends, -/// polyline vertices, arc endpoints — in world space. -fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec) { - use acadrust::EntityType as ET; - let p = |v: &acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); - let ocs = |point: (f64, f64, f64), normal: acadrust::types::Vector3| { - let world = crate::scene::view::transform::ocs_point_to_wcs( - point, - (normal.x, normal.y, normal.z), - ); - glam::DVec3::new(world.0, world.1, world.2) - }; - match e { - ET::Line(l) => { - out.push(p(&l.start)); - out.push(p(&l.end)); - } - ET::LwPolyline(pl) => { - for v in &pl.vertices { - out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal)); - } - } - ET::Polyline2D(pl) => { - for v in &pl.vertices { - out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal)); - } - } - ET::Polyline(pl) => { - for v in &pl.vertices { - out.push(p(&v.location)); - } - } - ET::Arc(a) => { - for &ang in &[a.start_angle, a.end_angle] { - out.push(ocs( - ( - a.center.x + a.radius * ang.cos(), - a.center.y + a.radius * ang.sin(), - a.center.z, - ), - a.normal, - )); - } - } - _ => {} - } -} - // ── TCASE helpers ────────────────────────────────────────────────────────── fn sentence_case(s: &str) -> String { let mut c = s.chars(); diff --git a/src/app/mod.rs b/src/app/mod.rs index b18e53012..c82f4b512 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -465,6 +465,8 @@ pub(super) struct OpenCADStudio { options_tab: crate::ui::window::options::OptionsTab, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. + pub quick_dimension_snap_priority: u8, /// When true (default), saving over an existing file first writes a `.bak` /// copy of it for recovery (#205). Toggle with the ISAVEBAK command. pub backup_on_save: bool, @@ -3210,6 +3212,7 @@ impl OpenCADStudio { dyn_input: true, options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, + quick_dimension_snap_priority: 0, backup_on_save: true, file_assoc_enabled: true, savetime_min: 10, diff --git a/src/app/settings.rs b/src/app/settings.rs index 8b65c73e5..c99e0bcb6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -198,6 +198,9 @@ pub struct UserSettings { pub osmode: i32, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. + #[serde(default)] + pub quick_dimension_snap_priority: u8, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -284,6 +287,7 @@ impl Default for UserSettings { // off (suppress bit 16384). osmode: 575 | OSMODE_SUPPRESS, texteditmode: false, + quick_dimension_snap_priority: 0, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bbf906eae..d5514c23b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -382,6 +382,7 @@ impl OpenCADStudio { self.snapper.snap_enabled, ), texteditmode: self.texteditmode, + quick_dimension_snap_priority: self.quick_dimension_snap_priority, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -440,6 +441,7 @@ impl OpenCADStudio { self.snapper.enabled = modes.into_iter().collect(); self.snapper.snap_enabled = snap_enabled; self.texteditmode = s.texteditmode; + self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; diff --git a/src/command.rs b/src/command.rs index d4a2312b3..d824f7bb6 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1249,6 +1249,12 @@ pub enum CmdResult { entity: EntityType, association: DimensionAssociationInput, }, + /// Commit several dimensions with independent association sources in one + /// undo step, then end the command. + CommitDimensionsAndExit(Vec<(EntityType, DimensionAssociationInput)>), + /// Persist the quick-dimension extension-origin priority while keeping the + /// active command at its current prompt (0 = endpoints, 1 = intersections). + SetQuickDimensionSnapPriority(u8), /// 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. diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index 151cfd4af..0cf3e4857 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -1,17 +1,15 @@ -// QDIM command — quick dimension: select objects then pick placement line. -// -// Workflow: -// 1. Select objects (window/click, Enter to finish selection) -// 2. Pick a point that defines the dimension-line position -// -// Creates one linear dimension per detected endpoint pair on the selected objects. +//! Quick dimension creation from a selected set of drawing entities. -use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::entities::{Dimension, DimensionDiameter, DimensionLinear, DimensionOrdinate, DimensionRadius}; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; +use cadkernel::geom2d::{characteristic_points, intersect, Curve, SnapKind, Tolerance}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + EntityTransform, SelectionEntity, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -27,129 +25,384 @@ pub fn tool() -> ToolDef { } } -enum Step { - Gathering, - PlaceLine { handles: Vec }, -} +#[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() -> Self { + 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: Step::Gathering, + 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), + // An intersection depends on two source curves. The + // current association record stores one source per + // extension origin, so do not bind it to an + // unrelated endpoint marker. + 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().min_by(|first, second| first.point.distance(datum).total_cmp(&second.point.distance(datum))) + .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); + 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(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(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(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(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 name(&self) -> &'static str { - "QDIM" - } + 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() + 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), } - Step::PlaceLine { .. } => t!("QDIM Specify dimension line position:").into_owned(), } - } - - fn is_selection_gathering(&self) -> bool { - matches!(self.step, Step::Gathering) - } - - fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { - if handles.is_empty() { - return CmdResult::Cancel; + 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), } - self.step = Step::PlaceLine { handles }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::PlaceLine { handles } = &self.step { - CmdResult::Relaunch( - // Keep the picked placement point in full f64 precision so the - // committed dimension coordinates are not quantized to f32. - format!("QDIM_PLACE {:.6} {:.6} {:.6}", pt.x, pt.y, pt.z), - handles.clone(), - ) - } else { - 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 { - CmdResult::Cancel - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); - if !matches!(self.step, Step::PlaceLine { .. }) { - return None; + 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, } - let d = 0.5_f32; - Some(WireModel { - point_marker: None, - taper_widths: Vec::new(), - pattern_stations: Vec::new(), - world_width: 0.0, - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "qdim_preview".into(), - points: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + } + + 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 point = self.plane.to_world(candidate.point); + [ + WireModel::solid_f64(format!("qdim_edit_{index}_x"), vec![ + [point.x - size, point.y, point.z], [point.x + size, point.y, point.z], + ], WireModel::CYAN, false), + WireModel::solid_f64(format!("qdim_edit_{index}_y"), vec![ + [point.x, point.y - size, point.z], [point.x, point.y + size, point.z], + ], WireModel::CYAN, false), + ] + }).collect(); + } + Vec::new() } } -/// Build a linear dimension between two points at the given dimension-line position. -#[allow(dead_code)] -pub fn make_linear_dim(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> EntityType { - let v = |p: DVec3| Vector3::new(p.x, p.y, p.z); - let mut dim = DimensionLinear::new(v(p1), v(p2)); - // Determine axis: horizontal if Δx > Δz, else vertical - let dx = (p2.x - p1.x).abs(); - let dz = (p2.z - p1.z).abs(); - dim.rotation = if dz > dx { - std::f64::consts::FRAC_PI_2 - } else { - 0.0 - }; - dim.definition_point = v(dim_pt); - dim.base.definition_point = v(dim_pt); - let text_pos = (p1 + p2) * 0.5; - dim.base.text_middle_point = v(text_pos); - dim.base.insertion_point = v(text_pos); - dim.base.actual_measurement = dim.measurement(); - EntityType::Dimension(Dimension::Linear(dim)) +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)], + } +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] }); // QdimCommand +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"] });