diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index d529dab91..2fad206a5 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -75,9 +75,10 @@ impl OpenCADStudio { "DIMCONTINUE" | "DCO" => { use crate::modules::annotate::dim_continue::DimContinueCommand; - let cmd = if let Some((p1, p2, dp, rot)) = find_last_linear_dim(&self.tabs[i].scene) + let cmd = if let Some((p1, p2, dp, rot, trot)) = + find_last_linear_dim(&self.tabs[i].scene) { - DimContinueCommand::from_base(p1, p2, dp, rot) + DimContinueCommand::from_base(p1, p2, dp, rot, trot) } else { DimContinueCommand::new() }; @@ -87,7 +88,8 @@ impl OpenCADStudio { "DIMBASELINE" | "DBA" => { use crate::modules::annotate::dim_baseline::DimBaselineCommand; - let cmd = if let Some((p1, p2, dp, rot)) = find_last_linear_dim(&self.tabs[i].scene) + let cmd = if let Some((p1, p2, dp, rot, trot)) = + find_last_linear_dim(&self.tabs[i].scene) { let doc = &self.tabs[i].scene.document; let dimdli = doc @@ -99,7 +101,7 @@ impl OpenCADStudio { }) .map(|s| s.dimdli as f32) .unwrap_or(1.5); - DimBaselineCommand::from_base(p1, p2, dp, rot, dimdli) + DimBaselineCommand::from_base(p1, p2, dp, rot, trot, dimdli) } else { DimBaselineCommand::new() }; @@ -502,10 +504,10 @@ impl OpenCADStudio { /// Returns `(first_point, second_point, definition_point, rotation_rad)` in world-space. fn find_last_linear_dim( scene: &crate::scene::Scene, -) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64)> { +) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> { use acadrust::entities::Dimension; let mut best_handle: u64 = 0; - let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64)> = None; + let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> = None; for entity in scene.document.entities() { if let acadrust::EntityType::Dimension(dim) = entity { @@ -530,7 +532,7 @@ fn find_last_linear_dim( d.base.definition_point.y as f32, d.base.definition_point.z as f32, ); - Some((p1, p2, dp, d.rotation)) + Some((p1, p2, dp, d.rotation, d.base.text_rotation)) } Dimension::Aligned(d) => { let p1 = glam::Vec3::new( @@ -551,7 +553,7 @@ fn find_last_linear_dim( let dx = (d.second_point.x - d.first_point.x) as f32; let dy = (d.second_point.y - d.first_point.y) as f32; let rot = dy.atan2(dx) as f64; - Some((p1, p2, dp, rot)) + Some((p1, p2, dp, rot, d.base.text_rotation)) } _ => None, }; diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index e5eb32b73..20f8c0d4f 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -34,11 +34,15 @@ fn base_props(base: &DimensionBase) -> Vec PropSection { d.definition_point, )); props.push(edit( - "Ext Rotation", + "Ext Rotation (deg)", "ext_line_rotation", - d.ext_line_rotation, + d.ext_line_rotation.to_degrees(), )); } Dimension::Linear(d) => { @@ -74,11 +78,11 @@ fn properties(dim: &Dimension) -> PropSection { d.second_point, d.definition_point, )); - props.push(edit("Rotation", "rotation", d.rotation)); + props.push(edit("Rotation (deg)", "rotation", d.rotation.to_degrees())); props.push(edit( - "Ext Rotation", + "Ext Rotation (deg)", "ext_line_rotation", - d.ext_line_rotation, + d.ext_line_rotation.to_degrees(), )); } Dimension::Radius(d) => { @@ -217,8 +221,8 @@ fn apply_base_prop(base: &mut DimensionBase, field: &str, value: &str) -> bool { base.text_user_positioned = true; assign_f64(value, &mut base.text_middle_point.z) } - "text_rotation" => assign_f64(value, &mut base.text_rotation), - "horizontal_direction" => assign_f64(value, &mut base.horizontal_direction), + "text_rotation" => assign_deg(value, &mut base.text_rotation), + "horizontal_direction" => assign_deg(value, &mut base.horizontal_direction), "line_spacing_factor" => assign_f64(value, &mut base.line_spacing_factor), _ => false, } @@ -232,6 +236,17 @@ fn assign_f64(value: &str, target: &mut f64) -> bool { true } +/// Parse a value entered in DEGREES and store it as radians. Dimension angle +/// fields are kept in radians internally but shown/edited in degrees, matching +/// arc.rs / text.rs so the properties panel reads consistently. +fn assign_deg(value: &str, target: &mut f64) -> bool { + let Some(v) = parse_f64(value) else { + return false; + }; + *target = v.to_radians(); + true +} + fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { if apply_base_prop(dim.base_mut(), field, value) { return; @@ -256,7 +271,11 @@ fn apply_linear_fields_aligned(d: &mut DimensionAligned, field: &str, value: &st field, value, ); - let _ = assign_f64(value, &mut d.ext_line_rotation); + // Only the oblique field touches ext_line_rotation — otherwise editing a + // coordinate (e.g. First X) would corrupt the dimension's obliquing. #181. + if field == "ext_line_rotation" { + let _ = assign_deg(value, &mut d.ext_line_rotation); + } } fn apply_linear_fields_linear(d: &mut DimensionLinear, field: &str, value: &str) { @@ -269,10 +288,10 @@ fn apply_linear_fields_linear(d: &mut DimensionLinear, field: &str, value: &str) ); match field { "rotation" => { - let _ = assign_f64(value, &mut d.rotation); + let _ = assign_deg(value, &mut d.rotation); } "ext_line_rotation" => { - let _ = assign_f64(value, &mut d.ext_line_rotation); + let _ = assign_deg(value, &mut d.ext_line_rotation); } _ => {} } @@ -604,7 +623,17 @@ fn apply_to_v3(target: &mut acadrust::types::Vector3, apply: &GripApply) { impl Grippable for Dimension { fn grips(&self) -> Vec { - let text = dv3(&self.base().text_middle_point); + // Auto-placed dimensions carry a zero text_middle_point sentinel; put + // the text grip at the style-default placement (default metrics) instead + // of the world origin, so it stays on the visible text and grabbable. + let text = { + let p = self.base().text_middle_point; + if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 { + dv3(&p) + } else { + dv3(&dimension_text_pos_f64(self, None, 2.5, 1.0)) + } + }; match self { Dimension::Linear(d) => vec![ square_grip(0, dv3(&d.first_point)), @@ -1557,23 +1586,27 @@ fn dimension_geometry( append_center_mark(&mut g, center, params.dimcen, radius); } Dimension::Diameter(d) => { - let p1 = lv(d.angle_vertex); - let p2 = lv(d.definition_point); - add_segment(&mut g.dim_lines, p1, p2); - append_arrow(&mut g, p1, normalized_or(p2 - p1, Vec3::X), arrow1); - append_arrow(&mut g, p2, normalized_or(p1 - p2, Vec3::X), arrow2); - // DIMETER leader: continue past p2 toward the text. + // angle_vertex is the circle centre and definition_point a point on + // the circle. The diameter line runs edge-to-edge THROUGH the centre + // (far edge → near edge), with arrows pointing inward at each edge. + let center = lv(d.angle_vertex); + let edge = lv(d.definition_point); + let far = center * 2.0 - edge; + add_segment(&mut g.dim_lines, far, edge); + append_arrow(&mut g, edge, normalized_or(far - edge, Vec3::X), arrow1); + append_arrow(&mut g, far, normalized_or(edge - far, Vec3::X), arrow2); + // Diameter leader: continue past the near edge toward the text. if d.leader_length.abs() > 1e-9 { let text = dimension_text_position(dim); - let leader_dir = normalized_or(text - p2, p2 - p1); + let leader_dir = normalized_or(text - edge, edge - far); add_segment( &mut g.dim_lines, - p2, - p2 + leader_dir * (d.leader_length as f32), + edge, + edge + leader_dir * (d.leader_length as f32), ); } - let radius = (p2 - p1).length() * 0.5; - append_center_mark(&mut g, (p1 + p2) * 0.5, params.dimcen, radius); + let radius = (edge - center).length(); + append_center_mark(&mut g, center, params.dimcen, radius); } Dimension::Angular2Ln(d) => { append_angular_dimension( @@ -1783,13 +1816,18 @@ fn append_angular_dimension( arrow1: &ArrowKind, arrow2: &ArrowKind, ) { - add_segment(&mut g.ext_lines, vertex, first); - add_segment(&mut g.ext_lines, vertex, second); - let radius = vertex.distance(arc_point); if radius <= 1e-6 { + add_segment(&mut g.ext_lines, vertex, first); + add_segment(&mut g.ext_lines, vertex, second); return; } + // Extension lines run from each measured point out to the dimension arc so + // there is no gap between a ray and its arc endpoint. (#181 / DIM-027) + let dir1 = normalized_or(first - vertex, Vec3::X); + let dir2 = normalized_or(second - vertex, Vec3::X); + add_segment(&mut g.ext_lines, first, vertex + dir1 * radius); + add_segment(&mut g.ext_lines, second, vertex + dir2 * radius); let start = (first.y - vertex.y).atan2(first.x - vertex.x); let mut end = (second.y - vertex.y).atan2(second.x - vertex.x); @@ -1936,29 +1974,7 @@ fn dimension_text_entity( let pos_f64 = dimension_text_pos_f64(dim, style, text_height, dim_scale); let base = dim.base(); - // DIMTIH/DIMTOH: when set, text is forced horizontal (rotation = 0) - // regardless of the dim line angle. Honour explicit base.text_rotation - // first, then horizontal_direction override, then DIMTIH/DIMTOH. - let dimtih = style.map(|s| s.dimtih).unwrap_or(false); - let dimtoh = style.map(|s| s.dimtoh).unwrap_or(false); - let dimjust = style.map(|s| s.dimjust).unwrap_or(0); - let rotation = if base.text_rotation.abs() > 1e-9 { - base.text_rotation - } else if base.horizontal_direction.abs() > 1e-9 { - // horizontal_direction is the in-plane reading direction the writer - // baked in (used for vertical / oblique dims). - base.horizontal_direction - } else if dimtih || dimtoh { - 0.0 - } else { - let mut r = dimension_text_natural_rotation(dim); - // DIMJUST 3/4: text reads along the extension lines, i.e. rotated 90° - // from the dimension line. - if dimjust == 3 || dimjust == 4 { - r += std::f64::consts::FRAC_PI_2; - } - r - }; + let rotation = dimension_text_rotation(dim, style); // Text style resolution priority: // 1. DIMTXSTY by handle (most reliable; survives rename) @@ -2056,6 +2072,31 @@ fn attachment_to_text_align( } } +/// Reading rotation for a dimension's measurement text, resolving the style +/// flags the same way the live renderer does: explicit text_rotation, then +/// horizontal_direction, then DIMTIH/DIMTOH force-horizontal, else the natural +/// dim-line angle (+90° for DIMJUST 3/4). Shared by the live text entity and +/// the bake so a reloaded dimension's text reads at the same angle. (#181) +fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { + let base = dim.base(); + let dimtih = style.map(|s| s.dimtih).unwrap_or(false); + let dimtoh = style.map(|s| s.dimtoh).unwrap_or(false); + let dimjust = style.map(|s| s.dimjust).unwrap_or(0); + if base.text_rotation.abs() > 1e-9 { + base.text_rotation + } else if base.horizontal_direction.abs() > 1e-9 { + base.horizontal_direction + } else if dimtih || dimtoh { + 0.0 + } else { + let mut r = dimension_text_natural_rotation(dim); + if dimjust == 3 || dimjust == 4 { + r += std::f64::consts::FRAC_PI_2; + } + r + } +} + fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let angle = match dim { Dimension::Linear(d) => d.rotation, @@ -2077,7 +2118,7 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { } } -fn dimension_text_value(dim: &Dimension, style: Option<&DimStyle>) -> Option { +pub(crate) fn dimension_text_value(dim: &Dimension, style: Option<&DimStyle>) -> Option { let (main, tol) = dimension_text_parts(dim, style)?; // Tolerance is appended inline for callers (e.g. fill rect width) that // don't render a separate tolerance entity. The visual pipeline that @@ -2731,13 +2772,12 @@ fn dimension_text_pos_f64( ) } _ => { - // Non-linear (radius / diameter / angular / ordinate): keep the - // saved text point when present for fidelity, else lift the natural - // mid point straight up by the style offset. - let p = base.text_middle_point; - if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 { - return p; - } + // Non-linear (radius / diameter / angular / ordinate): lift the + // natural mid point straight up by the style offset. A user-dragged + // text point is already returned by the `use_saved` gate above, so + // we must NOT short-circuit on a merely-nonzero text_middle_point + // 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, @@ -2763,6 +2803,33 @@ fn perp_sign_default() -> f64 { 1.0 } +/// Text placement (position, height, rotation) the live renderer uses for +/// `dim`, resolving its dimension style from `document`. Shared with the +/// dimension-block baking on save so a baked `*D` block places its measurement +/// text exactly where OCS draws it on screen — otherwise the text jumps when +/// the file is saved and reopened (the reload renders from the baked block). +/// `anno_scale` is the annotative scale (1.0 for a plain model-space bake). +pub(crate) fn baked_dimension_text( + dim: &Dimension, + document: &CadDocument, + anno_scale: f64, +) -> Option<(String, Vector3, f64, f64)> { + let style_name = dim.base().style_name.as_str(); + let style = document.dim_styles.iter().find(|s| { + s.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + }); + // None => the dimension's text is suppressed (user_text " "): bake no Text. + let value = dimension_text_value(dim, style)?; + let dim_scale = style + .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { anno_scale }) + .unwrap_or(1.0); + let dim_txt = style.map(|s| s.dimtxt * dim_scale).unwrap_or(2.5 * dim_scale); + let pos = dimension_text_pos_f64(dim, style, dim_txt, dim_scale); + let rot = dimension_text_rotation(dim, style); + Some((value, pos, dim_txt, rot)) +} + #[cfg(test)] mod dimtad_tests { use super::text_on_dim_line; diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 53be9b205..d36fe3be6 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -34,6 +34,9 @@ pub struct DimBaselineCommand { base_p1: Vec3, /// Direction along the measurement direction (0.0 = horizontal, PI/2 = vertical). rotation: f64, + /// Text reading rotation inherited from the base dim (keeps a UCS-aligned + /// chain's text consistent with the originating DIMLINEAR). 0 = natural. + text_rotation: f64, /// Unit vector perpendicular to the dimension axis, pointing toward the dim line side. perp: Vec3, /// Perpendicular distance of the NEXT dimension line from the extension-line axis. @@ -50,6 +53,7 @@ impl DimBaselineCommand { Self { base_p1: Vec3::ZERO, rotation: 0.0, + text_rotation: 0.0, perp: Vec3::Y, next_offset: DEFAULT_DIMDLI, dimdli: DEFAULT_DIMDLI, @@ -69,6 +73,7 @@ impl DimBaselineCommand { _p2: Vec3, definition_point: Vec3, rotation: f64, + text_rotation: f64, dimdli: f32, ) -> Self { // Measurement axis = the base dim's rotation angle (any angle, incl. a @@ -79,13 +84,19 @@ impl DimBaselineCommand { let dimdli = if dimdli.abs() < 1e-6 { DEFAULT_DIMDLI } else { - dimdli + dimdli.abs() }; - // Next baseline dim goes one DIMDLI further from the baseline. + // Stack each baseline outward — in the SIGN direction of the base dim's + // own offset. When the base dim sits on the -perp side (below / left of + // the points) a positive increment would march the stack back toward and + // across the points; the signed increment keeps it moving away. (#181) + let dir = if base_offset >= 0.0 { 1.0 } else { -1.0 }; + let dimdli = dimdli * dir; let next_offset = base_offset + dimdli; Self { base_p1: p1, rotation, + text_rotation, perp, next_offset, dimdli, @@ -94,6 +105,19 @@ impl DimBaselineCommand { } } +impl DimBaselineCommand { + /// The two dimension-line endpoints for the new baseline at `p2`: the fixed + /// base origin and `p2` each projected INDEPENDENTLY onto the dim line (at + /// the current stacking offset). Projecting both keeps the line straight + /// even when the two origins aren't level — a shared offset tilts it. (#181) + fn dim_line_pts(&self, p2: Vec3) -> (Vec3, Vec3) { + let target = self.base_p1.dot(self.perp) + self.next_offset; + let d1 = self.base_p1 + self.perp * (target - self.base_p1.dot(self.perp)); + let d2 = p2 + self.perp * (target - p2.dot(self.perp)); + (d1, d2) + } +} + impl CadCommand for DimBaselineCommand { fn name(&self) -> &'static str { "DIMBASELINE" @@ -117,9 +141,11 @@ impl CadCommand for DimBaselineCommand { // Build a new linear dimension. let mut dim = DimensionLinear::new(v3(p1), v3(p2)); dim.rotation = self.rotation; + if self.text_rotation.abs() > 1e-9 { + dim.base.text_rotation = self.text_rotation; + } - let dim_line_pt = p1 + self.perp * self.next_offset; - let dim_line_pt2 = p2 + self.perp * self.next_offset; + let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(p2); dim.definition_point = v3(dim_line_pt); dim.base.definition_point = v3(dim_line_pt); dim.base.text_middle_point = v3((dim_line_pt + dim_line_pt2) * 0.5); @@ -141,8 +167,7 @@ impl CadCommand for DimBaselineCommand { return None; } let p1 = self.base_p1; - let dim_line_pt = p1 + self.perp * self.next_offset; - let dim_line_pt2 = pt + self.perp * self.next_offset; + let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt); Some(WireModel { name: "dimbase_preview".into(), points: vec![ diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index bf4f22046..18da01b32 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -31,6 +31,9 @@ pub struct DimContinueCommand { chain_p1: Vec3, /// Direction along the dimension axis (0.0 = horizontal, PI/2 = vertical). rotation: f64, + /// Text reading rotation inherited from the base dim so a UCS-aligned chain + /// keeps its text consistent with the originating DIMLINEAR. 0 = natural. + text_rotation: f64, /// Absolute perpendicular coordinate (dot with `perp`) of the base /// dimension line. Each continued dim line is projected onto this exact /// coordinate so the whole chain stays collinear — even when the extension @@ -49,6 +52,7 @@ impl DimContinueCommand { Self { chain_p1: Vec3::ZERO, rotation: 0.0, + text_rotation: 0.0, dim_line_perp: 0.0, perp: Vec3::Y, ready: false, @@ -64,7 +68,13 @@ impl DimContinueCommand { /// `definition_point` — where the dim line was placed; its perpendicular /// coordinate is the line the whole chain stays collinear with. /// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim. - pub fn from_base(_p1: Vec3, p2: Vec3, definition_point: Vec3, rotation: f64) -> Self { + pub fn from_base( + _p1: Vec3, + p2: Vec3, + definition_point: Vec3, + rotation: f64, + text_rotation: f64, + ) -> Self { // Axis unit vector along the measurement direction — the base dim's // rotation angle (any angle, incl. a UCS-aligned one), not a world H/V. let axis = Vec3::new(rotation.cos() as f32, rotation.sin() as f32, 0.0); @@ -74,6 +84,7 @@ impl DimContinueCommand { Self { chain_p1: p2, rotation, + text_rotation, dim_line_perp, perp, ready: true, @@ -104,6 +115,9 @@ impl CadCommand for DimContinueCommand { // Build a new linear dimension. let mut dim = DimensionLinear::new(v3(p1), v3(p2)); dim.rotation = self.rotation; + if self.text_rotation.abs() > 1e-9 { + dim.base.text_rotation = self.text_rotation; + } // Dim line stays collinear with the base: project both extension // origins onto the base dim line's absolute perpendicular coordinate. diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index f4c105472..dc5c5e200 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -78,6 +78,9 @@ impl CadCommand for DimTeditCommand { acadrust::types::Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64); d.base_mut().text_middle_point = new_pt; d.base_mut().insertion_point = new_pt; + // Pin the text to this location, else the renderer recomputes + // the style-default placement and the move is discarded. + d.base_mut().text_user_positioned = true; } return CmdResult::ReplaceEntity(h, vec![ent]); } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index f12c2fa7b..deeec7d52 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -151,28 +151,28 @@ fn preview_wire(points: Vec) -> WireModel { } } -fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec { +/// Project the two extension origins onto the dimension line, which passes +/// through `def` along `axis`. Each origin is projected *independently*: a +/// single shared offset only lands both on the line when they are level, and +/// tilts the dimension line when they are not (e.g. measuring across sloped +/// points). See #181. +fn dim_line_endpoints(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> (Vec3, Vec3) { let perp = Vec3::new(-axis.y, axis.x, 0.0); - let offset = (def - first).dot(perp); - let d1 = first + perp * offset; - let d2 = second + perp * offset; - vec![ - first, - d1, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - second, - d2, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - d1, - d2, - ] + let dperp = def.dot(perp); + let d1 = first + perp * (dperp - first.dot(perp)); + let d2 = second + perp * (dperp - second.dot(perp)); + (d1, d2) +} + +fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec { + let (d1, d2) = dim_line_endpoints(first, second, def, axis); + let nan = Vec3::new(f32::NAN, f32::NAN, f32::NAN); + vec![first, d1, nan, second, d2, nan, d1, d2] } fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec3 { + let (d1, d2) = dim_line_endpoints(first, second, def, axis); let perp = Vec3::new(-axis.y, axis.x, 0.0); - let offset = (def - first).dot(perp); - let d1 = first + perp * offset; - let d2 = second + perp * offset; (d1 + d2) * 0.5 + perp * 0.15 } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index b326b8155..f7d45d787 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -71,7 +71,7 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec explode_mline(ml), - EntityType::Dimension(dim) => explode_dimension(dim), + EntityType::Dimension(dim) => explode_dimension(dim, document), _ => vec![], } } @@ -322,7 +322,142 @@ fn explode_mline(ml: &MLine) -> Vec { // ── Dimension explode ────────────────────────────────────────────────────── /// Convert a Dimension entity into Lines (geometry) + Text (label). -fn explode_dimension(dim: &Dimension) -> Vec { +/// A NULL-handle line segment for a baked dimension block. +fn dim_seg(a: Vector3, b: Vector3, common: &acadrust::entities::EntityCommon) -> EntityType { + let mut c = common.clone(); + c.handle = Handle::NULL; + EntityType::Line(LineEnt { + common: c, + start: a, + end: b, + ..LineEnt::new() + }) +} + +/// Open (two-stroke) arrowhead with its tip at `tip`, strokes pointing back +/// along the unit vector `(dx,dy)` (from the tip toward the dimension line), +/// sized `size`. Simple, valid in any reader, and far better than a bare line. +fn dim_arrowhead( + tip: Vector3, + dx: f64, + dy: f64, + size: f64, + common: &acadrust::entities::EntityCommon, +) -> Vec { + let ang = dy.atan2(dx); + let wing = 18f64.to_radians(); + let mk = |a: f64| Vector3::new(tip.x + size * a.cos(), tip.y + size * a.sin(), tip.z); + vec![ + dim_seg(tip, mk(ang + wing), common), + dim_seg(tip, mk(ang - wing), common), + ] +} + +/// Center-mark cross at `center`, arm length |DIMCEN|. Empty when DIMCEN == 0. +fn dim_center_mark( + center: Vector3, + dimcen: f64, + common: &acadrust::entities::EntityCommon, +) -> Vec { + let s = dimcen.abs(); + if s < 1e-9 { + return Vec::new(); + } + vec![ + dim_seg( + Vector3::new(center.x - s, center.y, center.z), + Vector3::new(center.x + s, center.y, center.z), + common, + ), + dim_seg( + Vector3::new(center.x, center.y - s, center.z), + Vector3::new(center.x, center.y + s, center.z), + common, + ), + ] +} + +/// Arc from angle `a1` to `a2` (radians, swept the short way) at `radius` about +/// `center`, approximated by straight chords (world f64). +fn dim_arc_segs( + center: Vector3, + radius: f64, + a1: f64, + a2: f64, + common: &acadrust::entities::EntityCommon, +) -> Vec { + use std::f64::consts::PI; + let mut sweep = a2 - a1; + while sweep > PI { + sweep -= 2.0 * PI; + } + while sweep < -PI { + sweep += 2.0 * PI; + } + let steps = 12usize.max((sweep.abs() / (PI / 36.0)).ceil() as usize); + let pt = |a: f64| Vector3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z); + let mut out = Vec::new(); + let mut prev = pt(a1); + for i in 1..=steps { + let cur = pt(a1 + sweep * (i as f64 / steps as f64)); + out.push(dim_seg(prev, cur, common)); + prev = cur; + } + out +} + +/// (DIMASZ, DIMCEN), DIMSCALE-applied, resolved from the dimension's style. +fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> (f64, f64) { + let name = dim.base().style_name.as_str(); + let style = doc.dim_styles.iter().find(|s| { + s.name.eq_ignore_ascii_case(name) + || (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + }); + let scale = style + .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) + .unwrap_or(1.0); + let dimasz = style.map(|s| s.dimasz * scale).unwrap_or(0.18 * scale).max(1e-6); + let dimcen = style.map(|s| s.dimcen * scale).unwrap_or(0.09 * scale); + (dimasz, dimcen) +} + +/// Baked geometry for an angular dimension: an extension line along each ray +/// out to the dimension arc, plus the swept arc itself (which is what makes it +/// read as an angle rather than two crossing lines — #181 / DIM-022). +fn angular_block_segs( + vertex: Vector3, + p1: Vector3, + p2: Vector3, + arc_loc: Vector3, + common: &acadrust::entities::EntityCommon, +) -> Vec { + let a1 = (p1.y - vertex.y).atan2(p1.x - vertex.x); + let a2 = (p2.y - vertex.y).atan2(p2.x - vertex.x); + let radius = ((arc_loc.x - vertex.x).powi(2) + (arc_loc.y - vertex.y).powi(2)).sqrt(); + let mut out = Vec::new(); + if radius < 1e-9 { + out.push(dim_seg(p1, vertex, common)); + out.push(dim_seg(p2, vertex, common)); + return out; + } + let e1 = Vector3::new( + vertex.x + a1.cos() * radius, + vertex.y + a1.sin() * radius, + vertex.z, + ); + let e2 = Vector3::new( + vertex.x + a2.cos() * radius, + vertex.y + a2.sin() * radius, + vertex.z, + ); + // Extension lines run from each measured ray point out to the arc. + out.push(dim_seg(p1, e1, common)); + out.push(dim_seg(p2, e2, common)); + out.extend(dim_arc_segs(vertex, radius, a1, a2, common)); + out +} + +fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { use acadrust::entities::Text; let base = dim.base(); @@ -362,37 +497,94 @@ fn explode_dimension(dim: &Dimension) -> Vec { result.push(make_seg(&d.first_point, &d1, &common)); result.push(make_seg(&d.second_point, &d2, &common)); result.push(make_seg(&d1, &d2, &common)); + let (asz, _) = dim_metrics(dim, doc); + let ml = ((d2.x - d1.x).powi(2) + (d2.y - d1.y).powi(2)).sqrt().max(1e-12); + let (ux, uy) = ((d2.x - d1.x) / ml, (d2.y - d1.y) / ml); + result.extend(dim_arrowhead(d1, ux, uy, asz, &common)); + result.extend(dim_arrowhead(d2, -ux, -uy, asz, &common)); let _ = len; } Dimension::Linear(d) => { - let angle = d.rotation.to_radians(); + // `rotation` is the dimension-line angle, already in radians (the + // same convention the live renderer uses) — do not convert again. + let angle = d.rotation; let perp_x = -(angle.sin()); let perp_y = angle.cos(); let fx = d.first_point.x; let fy = d.first_point.y; let sx = d.second_point.x; let sy = d.second_point.y; - let offset = - (d.definition_point.x - fx) * perp_x + (d.definition_point.y - fy) * perp_y; - let d1 = v3(fx + perp_x * offset, fy + perp_y * offset, d.first_point.z); - let d2 = v3(sx + perp_x * offset, sy + perp_y * offset, d.second_point.z); + // The dimension line passes through `definition_point` at `rotation`. + // Project each extension origin onto that line *independently*: a + // point's landing offset is (def - point)·perp. A single shared + // offset only lands both origins on the line when they are level; + // for sloped origins (e.g. a DIMCONTINUE chain over non-level + // points) it tilts the dimension line — issue #181. + let dperp = d.definition_point.x * perp_x + d.definition_point.y * perp_y; + let off1 = dperp - (fx * perp_x + fy * perp_y); + let off2 = dperp - (sx * perp_x + sy * perp_y); + let d1 = v3(fx + perp_x * off1, fy + perp_y * off1, d.first_point.z); + let d2 = v3(sx + perp_x * off2, sy + perp_y * off2, d.second_point.z); result.push(make_seg(&d.first_point, &d1, &common)); result.push(make_seg(&d.second_point, &d2, &common)); result.push(make_seg(&d1, &d2, &common)); + let (asz, _) = dim_metrics(dim, doc); + let ml = ((d2.x - d1.x).powi(2) + (d2.y - d1.y).powi(2)).sqrt().max(1e-12); + let (ux, uy) = ((d2.x - d1.x) / ml, (d2.y - d1.y) / ml); + result.extend(dim_arrowhead(d1, ux, uy, asz, &common)); + result.extend(dim_arrowhead(d2, -ux, -uy, asz, &common)); } Dimension::Radius(d) => { - result.push(make_seg(&d.angle_vertex, &d.definition_point, &common)); + // center -> point on circle, arrowhead at the point toward centre, + // plus the centre mark. + let (center, point) = (d.angle_vertex, d.definition_point); + result.push(make_seg(¢er, &point, &common)); + let (asz, cen) = dim_metrics(dim, doc); + let m = ((center.x - point.x).powi(2) + (center.y - point.y).powi(2)) + .sqrt() + .max(1e-12); + result.extend(dim_arrowhead( + point, + (center.x - point.x) / m, + (center.y - point.y) / m, + asz, + &common, + )); + result.extend(dim_center_mark(center, cen, &common)); } Dimension::Diameter(d) => { - result.push(make_seg(&d.angle_vertex, &d.definition_point, &common)); + // Full diameter line through the centre (far edge -> near edge), + // inward arrows at both edges, plus the centre mark. angle_vertex is + // the centre and definition_point the point on the circle. + let (center, edge) = (d.angle_vertex, d.definition_point); + let far = v3(2.0 * center.x - edge.x, 2.0 * center.y - edge.y, edge.z); + result.push(make_seg(&far, &edge, &common)); + let (asz, cen) = dim_metrics(dim, doc); + let m = ((edge.x - far.x).powi(2) + (edge.y - far.y).powi(2)) + .sqrt() + .max(1e-12); + let (ux, uy) = ((edge.x - far.x) / m, (edge.y - far.y) / m); + result.extend(dim_arrowhead(edge, -ux, -uy, asz, &common)); + result.extend(dim_arrowhead(far, ux, uy, asz, &common)); + result.extend(dim_center_mark(center, cen, &common)); } Dimension::Angular2Ln(d) => { - result.push(make_seg(&d.first_point, &d.angle_vertex, &common)); - result.push(make_seg(&d.second_point, &d.angle_vertex, &common)); + result.extend(angular_block_segs( + d.angle_vertex, + d.first_point, + d.second_point, + d.dimension_arc, + &common, + )); } Dimension::Angular3Pt(d) => { - result.push(make_seg(&d.first_point, &d.angle_vertex, &common)); - result.push(make_seg(&d.second_point, &d.angle_vertex, &common)); + result.extend(angular_block_segs( + d.angle_vertex, + d.first_point, + d.second_point, + d.definition_point, + &common, + )); } Dimension::Ordinate(d) => { result.push(make_seg(&d.feature_location, &d.definition_point, &common)); @@ -400,38 +592,53 @@ fn explode_dimension(dim: &Dimension) -> Vec { } } - // Text entity for the dimension label - let text_val = if let Some(u) = &base.user_text { - if !u.trim().is_empty() { - u.clone() - } else { - format!("{:.4}", dim.measurement()) - } - } else if !base.text.trim().is_empty() { - base.text.clone() - } else { - match dim { - Dimension::Radius(_) => format!("R{:.4}", dim.measurement()), - Dimension::Diameter(_) => format!("Ø{:.4}", dim.measurement()), - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => { - format!("{:.2}°", dim.measurement()) - } - _ => format!("{:.4}", dim.measurement()), - } - }; - - let mut text = Text::with_value(text_val, base.text_middle_point.clone()) - .with_height(base.line_spacing_factor.abs().max(0.1)) - .with_rotation(base.text_rotation); - text.common = common.clone(); - text.common.handle = Handle::NULL; - result.push(EntityType::Text(text)); + // Measurement text: value, position, height and rotation are all taken + // from the SAME live-render path (style-resolved formatting incl. + // DIMDEC/DIMLFAC/DIMPOST/units/`<>` and DIMTAD/DIMGAP placement), so the + // baked block matches the on-screen dimension and nothing changes when the + // file is saved and reopened — the reload renders from this block. A `None` + // means the text is suppressed (user_text " "), so bake no Text. #181. + if let Some((value, text_pos, text_h, text_rot)) = + crate::entities::dimension::baked_dimension_text(dim, doc, 1.0) + { + let mut text = Text::with_value(value, text_pos) + .with_height(text_h.max(0.1)) + .with_rotation(text_rot); + text.common = common.clone(); + text.common.handle = Handle::NULL; + result.push(EntityType::Text(text)); + } result } // ── Dimension block baking (DWG/DXF interop) ──────────────────────────────── +/// Mirror each dimension's authoritative geometric definition point into +/// `base.definition_point`, which is the field the DWG/DXF writer emits as +/// group 10. Edits (grips, properties, transforms) update the per-type struct +/// field but not `base`, so without this the saved group 10 goes stale and the +/// dimension's line / leader / origin jumps on reload (#181). Angular-2-line +/// keeps a distinct base point (the second line's point) and is left alone. +fn sync_dimension_base_points(doc: &mut CadDocument) { + for e in doc.entities_mut() { + if let EntityType::Dimension(d) = e { + let def = match d { + Dimension::Linear(x) => Some(x.definition_point), + Dimension::Aligned(x) => Some(x.definition_point), + Dimension::Radius(x) => Some(x.definition_point), + Dimension::Diameter(x) => Some(x.definition_point), + Dimension::Ordinate(x) => Some(x.definition_point), + Dimension::Angular3Pt(x) => Some(x.definition_point), + Dimension::Angular2Ln(_) => None, + }; + if let Some(p) = def { + d.base_mut().definition_point = p; + } + } + } +} + /// Smallest free `*D` anonymous block name in `doc`. fn next_dimension_block_name(doc: &CadDocument) -> String { let mut n = 0u64; @@ -462,6 +669,10 @@ fn next_dimension_block_name(doc: &CadDocument) -> String { /// DWG, or copied via the `*D`-cloning copy path) are left untouched so their /// original graphics are preserved. pub fn bake_dimension_blocks(doc: &mut CadDocument) { + // Keep group-10 (base.definition_point) in step with the per-type geometry + // before writing — see sync_dimension_base_points. + sync_dimension_base_points(doc); + // Handles of dimensions whose block reference is missing or dangling. let pending: Vec = doc .entities() @@ -483,7 +694,7 @@ pub fn bake_dimension_blocks(doc: &mut CadDocument) { Some(EntityType::Dimension(d)) => d.clone(), _ => continue, }; - let subs = explode_dimension(&dim); + let subs = explode_dimension(&dim, doc); if subs.is_empty() { continue; } @@ -600,4 +811,142 @@ mod tests { "a dimension that already owns a block must not be re-baked" ); } + + // Collect the line segments baked into the dimension's `*D` block. + fn baked_segments(doc: &CadDocument, block_name: &str) -> Vec<(Vector3, Vector3)> { + let rec = doc.block_records.get(block_name).expect("block record"); + doc.entities() + .filter_map(|e| match e { + EntityType::Line(l) if l.common.owner_handle == rec.handle => { + Some((l.start, l.end)) + } + _ => None, + }) + .collect() + } + + // A horizontal (rotation = 0) linear dimension whose two measured points sit + // at *different* heights must still bake a level dimension line — both + // extension origins project onto the same line. Regression test for #181, + // where a shared offset tilted the dimension line. + #[test] + fn linear_dim_line_stays_level_over_sloped_points() { + let mut doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 5.0, 0.0)); + d.rotation = 0.0; + d.definition_point = Vector3::new(0.0, 8.0, 0.0); + let handle = doc + .add_entity(EntityType::Dimension(Dimension::Linear(d))) + .unwrap(); + bake_dimension_blocks(&mut doc); + let name = match doc.get_entity(handle) { + Some(EntityType::Dimension(d)) => d.base().block_name.clone(), + _ => panic!("dimension missing"), + }; + // The dimension line is the segment spanning both x extents; it must be + // horizontal at the definition-point level (y = 8). + let dim_line = baked_segments(&doc, &name) + .into_iter() + .find(|(a, b)| (a.x - 0.0).abs() < 1e-6 && (b.x - 10.0).abs() < 1e-6) + .expect("dimension line segment"); + assert!( + (dim_line.0.y - 8.0).abs() < 1e-6 && (dim_line.1.y - 8.0).abs() < 1e-6, + "dimension line must be level at y=8, got {:?}", + dim_line + ); + } + + // An angular dimension must bake its swept ARC (not just two rays), else a + // saved+reloaded angular dim collapses to a V. The block should carry the + // two extension lines plus many arc chords. + #[test] + fn angular_dim_bakes_an_arc() { + use acadrust::entities::DimensionAngular3Pt; + let mut doc = CadDocument::new(); + let mut d = DimensionAngular3Pt::new( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + Vector3::new(0.0, 10.0, 0.0), + ); + d.definition_point = Vector3::new(5.0, 5.0, 0.0); // arc location + let handle = doc + .add_entity(EntityType::Dimension(Dimension::Angular3Pt(d))) + .unwrap(); + bake_dimension_blocks(&mut doc); + let name = match doc.get_entity(handle) { + Some(EntityType::Dimension(d)) => d.base().block_name.clone(), + _ => panic!("dimension missing"), + }; + let rec = doc.block_records.get(&name).expect("block"); + let lines = doc + .entities() + .filter(|e| matches!(e, EntityType::Line(l) if l.common.owner_handle == rec.handle)) + .count(); + assert!(lines > 5, "angular bake must include arc chords, got {lines} lines"); + } + + // A diameter dimension bakes a line edge-to-edge THROUGH the centre, not a + // radius-length line. The two extreme endpoints must be equidistant from the + // centre (angle_vertex) and the centre must lie between them. + #[test] + fn diameter_dim_bakes_through_center() { + use acadrust::entities::DimensionDiameter; + let mut doc = CadDocument::new(); + let center = Vector3::new(3.0, 4.0, 0.0); + let edge = Vector3::new(8.0, 4.0, 0.0); // radius 5 along +x + let mut d = DimensionDiameter::new(center, edge); + d.base.text_middle_point = Vector3::new(3.0, 9.0, 0.0); + let handle = doc + .add_entity(EntityType::Dimension(Dimension::Diameter(d))) + .unwrap(); + bake_dimension_blocks(&mut doc); + let name = match doc.get_entity(handle) { + Some(EntityType::Dimension(d)) => d.base().block_name.clone(), + _ => panic!("dimension missing"), + }; + // The longest baked segment is the diameter line; its endpoints span the + // full diameter (length ~= 2*radius = 10) centred on `center`. + let diam = baked_segments(&doc, &name) + .into_iter() + .find(|(a, b)| { + let len = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + (len - 10.0).abs() < 1e-6 + }) + .expect("diameter line spanning 2*radius"); + let mid_x = (diam.0.x + diam.1.x) * 0.5; + let mid_y = (diam.0.y + diam.1.y) * 0.5; + assert!( + (mid_x - center.x).abs() < 1e-6 && (mid_y - center.y).abs() < 1e-6, + "diameter line must be centred on the circle centre, mid=({mid_x},{mid_y})" + ); + } + + // A rotated linear dimension uses `rotation` directly (radians). Before the + // fix `to_radians()` shrank a 90° dim to ~1.57°, baking a nearly-horizontal + // line instead of a vertical one. + #[test] + fn rotated_linear_dim_bakes_at_its_angle() { + let mut doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 10.0, 0.0)); + d.rotation = std::f64::consts::FRAC_PI_2; // 90°, vertical dimension line + d.definition_point = Vector3::new(8.0, 0.0, 0.0); + let handle = doc + .add_entity(EntityType::Dimension(Dimension::Linear(d))) + .unwrap(); + bake_dimension_blocks(&mut doc); + let name = match doc.get_entity(handle) { + Some(EntityType::Dimension(d)) => d.base().block_name.clone(), + _ => panic!("dimension missing"), + }; + // Dimension line spans both y extents and must be vertical at x = 8. + let dim_line = baked_segments(&doc, &name) + .into_iter() + .find(|(a, b)| (a.y - 0.0).abs() < 1e-6 && (b.y - 10.0).abs() < 1e-6) + .expect("dimension line segment"); + assert!( + (dim_line.0.x - 8.0).abs() < 1e-6 && (dim_line.1.x - 8.0).abs() < 1e-6, + "dimension line must be vertical at x=8, got {:?}", + dim_line + ); + } }