From d33c52a0b81fefc6e369d17be34ce49ff4ef95ca Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:56:34 +0300 Subject: [PATCH 1/4] Add associative radius dimension workflow --- Cargo.lock | 2 +- Cargo.toml | 4 +- src/modules/annotate/radius_dim.rs | 140 ++++++++---- src/scene/dimension_assoc.rs | 345 +++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808da0125..78687c1df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=8c2cc9a#8c2cc9ac6de7ff8f129c0522fdc50216970faa1d" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index dc49bfb1c..663dcb733 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8c2cc9a", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "8c2cc9a" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 6bb9ec58e..8aa5ec5fe 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -1,5 +1,5 @@ use acadrust::entities::{Dimension, DimensionRadius}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{CadCommand, CmdResult, WorkingPlane}; @@ -20,14 +20,12 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - RadiusPoint(DVec3), - TextPoint { center: DVec3, point: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct RadiusDimensionCommand { step: Step, - plane: WorkingPlane, /// Optional text that replaces the measured value (None = measurement). text_override: Option, /// True while the next typed line is captured as the text override. @@ -36,24 +34,28 @@ pub struct RadiusDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl RadiusDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } impl CadCommand for RadiusDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -62,48 +64,57 @@ impl CadCommand for RadiusDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMRADIUS Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMRADIUS Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMRADIUS Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMRADIUS Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMRADIUS Specify center point:").into_owned(), - Step::RadiusPoint(_) => t!("DIMRADIUS Specify radius point:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMRADIUS Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => t!("DIMRADIUS Select arc, circle, or polyline arc:").into_owned(), + Step::DimLine(_) => { + t!("DIMRADIUS Specify dimension line location [Mtext/Text/Angle]:").into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::RadiusPoint(pt); - CmdResult::NeedPoint - } - Step::RadiusPoint(center) => { - self.step = Step::TextPoint { center, point: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, point } => { - let center = self.plane.to_local(center); - let point = self.plane.to_local(point); - let pt = self.plane.to_local(pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let center_world = dvec(source.center_world()); + let point_world = dvec(source.chord_at(pt.to_array())); + let center = source_plane.to_local(center_world); + let point = source_plane.to_local(point_world); + let pt = source_plane.to_local(pt); let mut dim = DimensionRadius::new(v3(center), v3(point)); dim.base.definition_point = v3(point); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); + dim.base.text_user_positioned = true; dim.leader_length = point.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Radius(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), + source: self.source_handle, + } } } } @@ -162,8 +173,17 @@ impl CadCommand for RadiusDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine(_)) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -175,18 +195,54 @@ impl CadCommand for RadiusDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::RadiusPoint(center) => Some(preview_wire(vec![center.as_vec3(), pt])), - Step::TextPoint { center, point } => Some(preview_wire(vec![ - center.as_vec3(), - point.as_vec3(), + Step::SelectObject => None, + Step::DimLine(source) => { + let center = dvec(source.center_world()).as_vec3(); + let point = dvec(source.chord_at(pt.to_array())).as_vec3(); + Some(preview_wire(vec![ + center, + point, Vec3::new(f32::NAN, f32::NAN, f32::NAN), - point.as_vec3(), - pt, - ])), + point, + pt.as_vec3(), + ])) + } } } } @@ -195,6 +251,10 @@ fn v3(pt: DVec3) -> Vector3 { Vector3::new(pt.x, pt.y, pt.z) } +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 2814695ea..d8168fa24 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -8,10 +8,193 @@ use acadrust::EntityType; use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve as KernelCurve, }; +use cadkernel::space::Plane; use std::f64::consts::TAU; use super::{ChangeKind, Scene}; +#[derive(Clone, Copy, Debug)] +pub(crate) struct RadialSourceGeometry { + pub plane: Plane, + pub center: [f64; 2], + pub radius: f64, + pub start_angle: f64, + pub sweep: f64, + pub limited: bool, + pub marker: i32, +} + +impl RadialSourceGeometry { + pub(crate) fn center_world(self) -> Vector3 { + vector3(self.plane.point_at(self.center)) + } + + pub(crate) fn point_at_angle(self, angle: f64) -> Vector3 { + vector3(self.plane.point_at([ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ])) + } + + pub(crate) fn angle_at(self, point: DPoint) -> f64 { + let projected = self.plane.project(point).unwrap_or(self.center); + (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]) + } + + pub(crate) fn chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point)) + } + + fn contains_angle(self, angle: f64) -> bool { + if !self.limited { + return true; + } + signed_progress(self.start_angle, angle, self.sweep) + .is_some_and(|progress| progress <= self.sweep.abs() + 1e-10) + } + + fn distance_squared_to(self, point: DPoint) -> f64 { + let Some(projected) = self.plane.project(point) else { + return f64::INFINITY; + }; + let angle = (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]); + let radial = ((projected[0] - self.center[0]).hypot(projected[1] - self.center[1]) + - self.radius) + .abs(); + let candidate = if self.contains_angle(angle) { + [ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ] + } else { + let start = [ + self.center[0] + self.radius * self.start_angle.cos(), + self.center[1] + self.radius * self.start_angle.sin(), + ]; + let end_angle = self.start_angle + self.sweep; + let end = [ + self.center[0] + self.radius * end_angle.cos(), + self.center[1] + self.radius * end_angle.sin(), + ]; + let start_distance = squared_2d(start, projected); + let end_distance = squared_2d(end, projected); + if start_distance <= end_distance { start } else { end } + }; + let planar = if self.contains_angle(angle) { + radial * radial + } else { + squared_2d(candidate, projected) + }; + let world = self.plane.point_at(projected); + let dx = world[0] - point[0]; + let dy = world[1] - point[1]; + let dz = world[2] - point[2]; + planar + dx * dx + dy * dy + dz * dz + } +} + +type DPoint = [f64; 3]; + +fn vector3(point: DPoint) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> DPoint { + [point.x, point.y, point.z] +} + +fn squared_2d(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +fn signed_progress(start: f64, angle: f64, sweep: f64) -> Option { + if !start.is_finite() || !angle.is_finite() || !sweep.is_finite() || sweep.abs() <= 1e-12 { + return None; + } + let progress = if sweep > 0.0 { + (angle - start).rem_euclid(TAU) + } else { + (start - angle).rem_euclid(TAU) + }; + Some(progress) +} + +fn radial_candidates(entity: &EntityType) -> Vec { + let Some(planar) = crate::entities::curve::entity_curve(entity) else { + return Vec::new(); + }; + match planar.curve { + KernelCurve::Circle(circle) => vec![RadialSourceGeometry { + plane: planar.plane, + center: circle.centre, + radius: circle.radius, + start_angle: 0.0, + sweep: TAU, + limited: false, + marker: 0, + }], + KernelCurve::Arc(arc) => vec![RadialSourceGeometry { + plane: planar.plane, + center: arc.centre, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep(), + limited: true, + marker: 0, + }], + KernelCurve::Polyline(polyline) => (0..polyline.vertices.len()) + .filter_map(|index| { + let arc = polyline.segment_arc(index)?; + Some(RadialSourceGeometry { + plane: planar.plane, + center: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep, + limited: true, + marker: index as i32, + }) + }) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn radial_source_at( + entity: &EntityType, + point: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) +} + +fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { + radial_candidates(entity) + .into_iter() + .find(|candidate| candidate.marker == marker) +} + +fn radial_source_matching( + entity: &EntityType, + center: Vector3, + radius: f64, + chord: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) +} + fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { let dx = first.x - second.x; let dy = first.y - second.y; @@ -172,6 +355,70 @@ pub(crate) fn dimension_is_associative( }) } +pub(crate) fn radial_extension_points( + document: &acadrust::CadDocument, + dimension: Handle, + gap: f64, +) -> Option> { + let EntityType::Dimension(Dimension::Radius(radius_dimension)) = + document.get_entity(dimension)? + else { + return None; + }; + let association = document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + })?; + let reference = association.references[0].first()?; + let source = document.get_entity(*reference.xrefs.first()?)?; + let radial = radial_source_for_marker(source, reference.main_gs_marker)?; + if !radial.limited || radial.radius <= 1e-12 { + return None; + } + let target = radial.angle_at(dpoint(radius_dimension.definition_point)); + if radial.contains_angle(target) { + return None; + } + + let end = radial.start_angle + radial.sweep; + let direction = radial.sweep.signum(); + let from_start = if direction > 0.0 { + (radial.start_angle - target).rem_euclid(TAU) + } else { + (target - radial.start_angle).rem_euclid(TAU) + }; + let from_end = if direction > 0.0 { + (target - end).rem_euclid(TAU) + } else { + (end - target).rem_euclid(TAU) + }; + let (origin, extension_direction, span) = if from_start <= from_end { + (radial.start_angle, -direction, from_start) + } else { + (end, direction, from_end) + }; + let gap_angle = gap.max(0.0) / radial.radius; + if span <= gap_angle + 1e-10 { + return None; + } + let visible_span = span - gap_angle; + let first_angle = origin + extension_direction * gap_angle; + let segments = (visible_span / (5.0_f64.to_radians())).ceil().max(1.0) as usize; + Some( + (0..=segments) + .map(|index| { + let fraction = index as f64 / segments as f64; + radial.point_at_angle(first_angle + extension_direction * visible_span * fraction) + }) + .collect(), + ) +} + impl Scene { pub(crate) fn attach_dimension_association( &mut self, @@ -182,6 +429,41 @@ impl Scene { else { return; }; + if let Dimension::Radius(radius_dimension) = entity { + let Some(source) = sources.into_iter().flatten().next() else { + return; + }; + let Some(source_entity) = self.document.get_entity(source) else { + return; + }; + let measured_radius = radius_dimension.measurement(); + let Some(radial) = radial_source_matching( + source_entity, + radius_dimension.angle_vertex, + measured_radius, + radius_dimension.definition_point, + ) else { + return; + }; + let angle = radial.angle_at(dpoint(radius_dimension.definition_point)); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: radius_dimension.definition_point, + ..AssocDimensionReference::default() + }; + self.store_dimension_association( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + [Some(source), None], + ); + return; + } let Some(source_data) = dimension_points(entity) else { return; }; @@ -235,6 +517,16 @@ impl Scene { } } + self.store_dimension_association(dimension, references, associativity, sources); + } + + fn store_dimension_association( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + sources: [Option; 2], + ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); object.handle = association_handle; @@ -270,6 +562,34 @@ impl Scene { else { return [None, None]; }; + if let Dimension::Radius(radius) = entity { + let measurement = radius.measurement(); + let source = self + .document + .entities() + .filter(|candidate| candidate.common().handle != dimension) + .filter_map(|candidate| { + let radial = radial_source_matching( + candidate, + radius.angle_vertex, + measurement, + radius.definition_point, + )?; + let center_error = point_distance_squared( + radial.center_world(), + radius.angle_vertex, + ); + let radius_error = (radial.radius - measurement).powi(2); + let tolerance = measurement.abs().max(1.0) * 1e-9; + (center_error + radius_error <= tolerance * tolerance).then_some(( + center_error + radius_error, + candidate.common().handle, + )) + }) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle); + return [source, None]; + } let Some(points) = dimension_points(entity) else { return [None, None]; }; @@ -318,6 +638,12 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { + let radial_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; + Some((radial, reference.osnap_distance)) + }); let first = association.references[0] .first() .and_then(|reference| resolve_reference(self, reference)); @@ -353,6 +679,25 @@ impl Scene { aligned.base.actual_measurement = aligned.measurement(); aligned.base.definition_point = aligned.definition_point; } + Dimension::Radius(radius) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = radius.definition_point; + let new_center = radial.center_world(); + let new_chord = radial.point_at_angle(angle); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + radius.angle_vertex = new_center; + radius.definition_point = new_chord; + radius.base.definition_point = new_chord; + radius.base.text_middle_point = radius.base.text_middle_point + delta; + radius.base.insertion_point = radius.base.insertion_point + delta; + radius.base.actual_measurement = radius.measurement(); + } _ => continue, } refreshed.push((association.dimension, ChangeKind::Modified)); From 205893c784ad0b0430a00d149fe9e5eab5c3c25c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:56:35 +0300 Subject: [PATCH 2/4] Align radius dimension properties and graphics --- src/entities/dim_override.rs | 25 ++++ src/entities/dimension.rs | 228 +++++++++++++++++++++++++++++++---- 2 files changed, 227 insertions(+), 26 deletions(-) diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 13ce51b8a..868bbab64 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -352,6 +352,7 @@ fn inherited_real(doc: &CadDocument, handle: Handle, code: i16) -> f64 { }; match code { DIMGAP => style.dimgap, + DIMCEN => style.dimcen, DIMTP => style.dimtp, DIMTM => style.dimtm, _ => 0.0, @@ -390,6 +391,30 @@ pub fn set_property( value: &str, ) -> bool { let trimmed = value.trim(); + if field == "dim_center_type" { + let current = inherited_real(doc, handle, DIMCEN); + let size = current.abs().max(0.01); + let value = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0.0, + "mark" => size, + "lines" => -size, + _ => return false, + }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } + if field == "dim_center_size" { + let Ok(size) = trimmed.parse::() else { + return false; + }; + if !size.is_finite() || size < 0.0 { + return false; + } + let current = inherited_real(doc, handle, DIMCEN); + let value = if current < 0.0 { -size } else { size }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } let handle_field = match field { "dim_arrowhead_1" => Some(DIMBLK1), "dim_arrowhead_2" => Some(DIMBLK2), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ae3032661..3639654c0 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -86,6 +86,23 @@ fn base_props(base: &DimensionBase) -> Vec Vec { + if let Dimension::Radius(radius) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(radius.base.style_name.clone()), + }, + edit( + t!("Leader Length").as_ref(), + "leader_length", + radius.leader_length, + ), + ], + }]; + } let compact_linear = match dim { Dimension::Linear(d) => Some(( &d.base, @@ -1636,7 +1653,23 @@ pub fn style_sections( let linetype = |code, inherited| { linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; - let arrow_1 = arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name); + let arrow_1 = if matches!(dimension, Dimension::Radius(_)) { + let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let inherited_name = if s.dimblk1.is_null() { + &s.dimblk_name + } else { + &s.dimblk1_name + }; + block_name( + document, + ov::handle(data, ov::DIMBLK1) + .or_else(|| ov::handle(data, ov::DIMBLK)) + .unwrap_or(inherited), + inherited_name, + ) + } else { + arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name) + }; let arrow_2 = arrow_name(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name); for current in [&arrow_1, &arrow_2] { if !arrow_options.contains(current) { @@ -1682,7 +1715,7 @@ pub fn style_sections( _ => choice_value("None", &["None", "Background", "Color"]), }; - vec![ + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ @@ -2312,7 +2345,49 @@ pub fn style_sections( ), ], }, - ] + ]; + + if matches!(dimension, Dimension::Radius(_)) { + let dimcen = real(ov::DIMCEN, s.dimcen); + let center_type = if dimcen > 1e-12 { + "Mark" + } else if dimcen < -1e-12 { + "Lines" + } else { + "None" + }; + if let Some(lines) = sections + .iter_mut() + .find(|section| section.title == t!("Lines & Arrows").as_ref()) + { + const RADIUS_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrow_size", + "dim_line_lineweight", + "dim_line_1", + "dim_line_color", + "dim_linetype", + ]; + lines + .props + .retain(|property| RADIUS_LINE_FIELDS.contains(&property.field)); + lines.props.push(choice( + t!("Center mark").as_ref(), + "dim_center_type", + center_type, + &["None", "Mark", "Lines"], + true, + )); + lines.props.push(number( + t!("Center size").as_ref(), + "dim_center_size", + dimcen.abs(), + center_type != "None", + )); + } + } + + sections } fn property(label: &str, field: &'static str, value: PropValue) -> Property { @@ -2835,7 +2910,16 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if dimsah { + if matches!(dim, Dimension::Radius(_)) { + let handle = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let arrow = arrow_from_block_with_deferred_hatch( + document, + handle, + dimasz, + defer_arrow_hatches, + ); + (arrow.clone(), arrow) + } else if dimsah { ( arrow_from_block_with_deferred_hatch( document, @@ -2894,6 +2978,11 @@ fn tessellate_dimension_inner( None } }; + let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); + let text_is_outside = dimension_text_is_outside(dim, style); + let horizontal_text = style.is_some_and(|style| { + (text_is_outside && style.dimtoh) || (!text_is_outside && style.dimtih) + }); let mut geom = dimension_geometry( dim, @@ -2912,6 +3001,8 @@ fn tessellate_dimension_inner( text_width, dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), + text_position, + horizontal_text, text_break, }, SuppressFlags { @@ -2922,6 +3013,17 @@ fn tessellate_dimension_inner( }, ); + if !dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + handle, + dimexo as f64, + ) { + let points: Vec = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geom.ext_lines, &points); + } + } + // DIMTMOVE = 1: when the saved text_middle_point sits far from the // dim-line anchor, draw a short leader connecting them. (=0 anchors text // to the dim line — no leader; =2 frees text without a leader.) @@ -3408,7 +3510,7 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { let off2 = def.dot(perp) - second.dot(perp); (first + perp * off1 + second + perp * off2) * 0.5 } - Dimension::Radius(d) => lv(d.definition_point), + Dimension::Radius(_) => return None, Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, _ => return None, }; @@ -3479,6 +3581,8 @@ struct DimLineParams { text_width: f32, dimatfit: i16, dimtofl: bool, + text_position: Vec3, + horizontal_text: bool, /// Text box (local centre, half-width, half-height) used to break the /// dimension line where the text sits on it, so a DIMTFILL background reads /// over the line. None when the text doesn't overlap the line. @@ -3533,7 +3637,7 @@ fn dimension_geometry( Dimension::Radius(d) => { let center = lv(d.angle_vertex); let point = lv(d.definition_point); - let text = dimension_text_position(dim); + let text = params.text_position; // A jogged radius dim (marked via XData) replaces the straight radial // leader with a foreshortened zig-zag at ~45° near its midpoint. let jogged = dim @@ -3551,29 +3655,50 @@ fn dimension_geometry( let mid = center + u * (dist * 0.5); let a = mid - u * half + perp * half; let b = mid + u * half - perp * half; - add_segment(&mut g.dim_lines, center, a); - add_segment(&mut g.dim_lines, a, b); - add_segment(&mut g.dim_lines, b, point); - } else { + if !suppress.dim1 { + add_segment(&mut g.dim_lines, center, a); + add_segment(&mut g.dim_lines, a, b); + add_segment(&mut g.dim_lines, b, point); + } + } else if !suppress.dim1 { add_segment(&mut g.dim_lines, center, point); } - // Honour leader_length: extend from the arrow tip past it - // toward the text by that distance along (text - point). - let leader_dir = normalized_or(text - point, Vec3::X); - let leader = if d.leader_length.abs() > 1e-9 { - point + leader_dir * (d.leader_length as f32) - } else { - text - }; - add_segment(&mut g.dim_lines, point, leader); + let radius = (point - center).length(); + let text_is_outside = text.distance(center) > radius + 1e-5; + if text_is_outside && !suppress.dim1 { + let radial = normalized_or(point - center, Vec3::X); + let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs()); + if params.horizontal_text + && angle_from_horizontal > 15.0_f32.to_radians() + && radial.y.abs() > 1e-6 + { + let travel = (text.y - point.y) / radial.y; + if travel > 0.0 { + let elbow = point + radial * travel; + let landing_gap = params.text_width * 0.5 + params.arrow_len; + let landing = Vec3::new( + text.x - radial.x.signum() * landing_gap, + text.y, + text.z, + ); + add_segment(&mut g.dim_lines, point, elbow); + add_segment(&mut g.dim_lines, elbow, landing); + } else { + add_segment(&mut g.dim_lines, point, text); + } + } else { + add_segment(&mut g.dim_lines, point, text); + } + } append_arrow( &mut g, point, normalized_or(center - point, Vec3::X), arrow1, ); - let radius = (point - center).length(); - append_center_mark(&mut g, center, params.dimcen, radius); + if text_is_outside { + append_center_mark(&mut g, center, params.dimcen, radius); + } } Dimension::Diameter(d) => { // angle_vertex is the circle centre and definition_point a point on @@ -4288,6 +4413,34 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool let Some(style) = style else { return false; }; + if let Dimension::Radius(radius) = dim { + let dx = radius.definition_point.x - radius.angle_vertex.x; + let dy = radius.definition_point.y - radius.angle_vertex.y; + let available = dx.hypot(dy); + if dim.base().text_user_positioned { + let text = dim.base().text_middle_point; + return (text.x - radius.angle_vertex.x) + .hypot(text.y - radius.angle_vertex.y) + > available + 1e-9; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let insufficient = text_width + arrow > available; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + } let (first, second, axis) = match dim { Dimension::Linear(d) => ( d.first_point, @@ -4338,6 +4491,8 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } + Dimension::Radius(d) => (d.definition_point.y - d.angle_vertex.y) + .atan2(d.definition_point.x - d.angle_vertex.x), _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -5119,6 +5274,32 @@ fn dimension_text_pos_f64( dimtad, ) } + Dimension::Radius(d) => { + let dx = d.definition_point.x - d.angle_vertex.x; + let dy = d.definition_point.y - d.angle_vertex.y; + let radius = dx.hypot(dy).max(1e-12); + let ux = dx / radius; + let uy = dy / radius; + let outside = dimension_text_is_outside(dim, style); + let (mut x, mut y) = if outside { + let distance = arrow + text_w * 0.5 + dimgap; + ( + d.definition_point.x + ux * distance, + d.definition_point.y + uy * distance, + ) + } else { + ( + d.angle_vertex.x + ux * radius * 0.5, + d.angle_vertex.y + uy * radius * 0.5, + ) + }; + if dimtad != 0 { + let sign = if dimtad == 4 { -1.0 } else { 1.0 }; + x += -uy * perp_off * sign; + y += ux * perp_off * sign; + } + Vector3::new(x, y, d.definition_point.z) + } _ => { // Non-linear (radius / diameter / angular / ordinate): lift the // natural mid point straight up by the style offset. A user-dragged @@ -5127,11 +5308,6 @@ fn dimension_text_pos_f64( // here — that would ignore the style placement for auto-placed dims // and make a re-style a no-op. (#181) let mid = match dim { - Dimension::Radius(d) => Vector3::new( - (d.angle_vertex.x + d.definition_point.x) * 0.5, - (d.angle_vertex.y + d.definition_point.y) * 0.5, - (d.angle_vertex.z + d.definition_point.z) * 0.5, - ), Dimension::Diameter(d) => Vector3::new( (d.angle_vertex.x + d.definition_point.x) * 0.5, (d.angle_vertex.y + d.definition_point.y) * 0.5, From f7d8522520c3ab87120ba97b63a079f30a831b7a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:14:46 +0300 Subject: [PATCH 3/4] Restore radius extension line properties --- src/entities/dimension.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 3639654c0..68873b443 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -2364,9 +2364,15 @@ pub fn style_sections( "dim_arrowhead_1", "dim_arrow_size", "dim_line_lineweight", + "dim_ext_line_lineweight", "dim_line_1", "dim_line_color", "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", ]; lines .props From 656501e91d499fb9cf0753f6704328c3f4b7075b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:28:00 +0300 Subject: [PATCH 4/4] Remove extra radius primary unit properties --- src/entities/dimension.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 68873b443..eaa45ee43 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -2391,6 +2391,17 @@ pub fn style_sections( center_type != "None", )); } + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props.retain(|property| { + !matches!( + property.field, + "dim_sub_units_suffix" | "dim_sub_units_scale" + ) + }); + } } sections