diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index bc3fa9a2b..470022408 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3431,6 +3431,26 @@ fn tessellate_dimension_inner( return wires; } } + if let Some((local, normal)) = linear_dimension_in_ocs(dim) { + let mut wires = tessellate_dimension_inner( + document, + handle, + &local, + selected, + entity_color, + line_weight_px, + anno_scale, + selected_set, + active_viewport, + bg_color, + None, + world_per_pixel, + ); + for wire in &mut wires { + map_wire_ocs_to_wcs(wire, normal); + } + return wires; + } let name = handle.value().to_string(); // (Baked-block fast path moved up into scene::tessellate_entity so the // recursive call goes through the LOD ladder, not the kernel path.) @@ -5226,6 +5246,50 @@ pub(crate) fn large_radial_dimension_in_ocs( Some((local, normal)) } +/// A linear or aligned dimension in a plane off world Z, moved into its own +/// OCS. Their builders read the offset and extension directions in XY, so +/// they draw it there and the result is mapped back onto the plane. `None` +/// for a dimension in world XY or of another kind. +pub(crate) fn linear_dimension_in_ocs(dimension: &Dimension) -> Option<(Dimension, Vector3)> { + let length = dimension.base().normal.length(); + if !length.is_finite() || length <= 1.0e-12 { + return None; + } + let normal = dimension.base().normal / length; + if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 { + return None; + } + let normal_tuple = (normal.x, normal.y, normal.z); + let to_ocs = |point: Vector3| { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + normal_tuple, + ); + Vector3::new(point.0, point.1, point.2) + }; + let mut local = dimension.clone(); + match &mut local { + Dimension::Linear(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + Dimension::Aligned(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + _ => return None, + } + Some((local, normal)) +} + fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { let normal_tuple = (normal.x, normal.y, normal.z); let map = |x: f64, y: f64, z: f64| { @@ -7687,4 +7751,79 @@ mod linear_transform_tests { ); assert_measures_ten(&dim); } + + /// Every stroke point the dimension draws, with its low residual added. + fn stroke_points(dim: &Dimension) -> Vec { + let document = CadDocument::new(); + let wires = dim.tessellate( + &document, + Handle::new(0x40), + false, + [1.0; 4], + 1.0, + 1.0, + &rustc_hash::FxHashSet::default(), + None, + [0.0, 0.0, 0.0, 1.0], + None, + None, + ); + let mut points = Vec::new(); + for wire in &wires { + for (index, high) in wire.points.iter().enumerate() { + if !high[0].is_finite() { + continue; + } + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + points.push(DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + )); + } + } + points + } + + /// Turned 90° about X into the XZ plane, the dimension has to draw the + /// same strokes turned with it. + fn assert_draws_turned_with_its_plane(flat: Dimension) { + let turn = glam::DQuat::from_axis_angle(DVec3::X, FRAC_PI_2); + let expected: Vec = stroke_points(&flat).into_iter().map(|p| turn * p).collect(); + assert!(!expected.is_empty(), "the dimension draws no strokes"); + let mut turned = flat; + apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: FRAC_PI_2, + }, + ); + let drawn = stroke_points(&turned); + assert_eq!(drawn.len(), expected.len(), "stroke point count"); + for (index, (got, want)) in drawn.iter().zip(&expected).enumerate() { + assert!( + got.abs_diff_eq(*want, 1e-3), + "point {index}: expected {want:?}, got {got:?}" + ); + } + } + + /// A linear dimension in a working plane off world Z draws in that plane. + /// Its offset used to be read in world XY only, so once it ran along Z the + /// dimension line fell onto the measured points and the extension lines + /// stuck out along world Y. + #[test] + fn a_linear_dimension_off_world_z_draws_in_its_own_plane() { + assert_draws_turned_with_its_plane(horizontal()); + } + + /// An aligned dimension draws through the same builder and plane. + #[test] + fn an_aligned_dimension_off_world_z_draws_in_its_own_plane() { + let mut d = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(6.0, 8.0, 0.0)); + d.definition_point = Vector3::new(2.0, 11.0, 0.0); + assert_draws_turned_with_its_plane(Dimension::Aligned(d)); + } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 52606f06e..5243e8af2 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { .collect(); } } + if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) { + let (x_axis, y_axis) = + crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + let plane = WorkingPlane::new( + DVec3::ZERO, + DVec3::new(x_axis.0, x_axis.1, x_axis.2), + DVec3::new(y_axis.0, y_axis.1, y_axis.2), + ); + return explode_dimension(&local, doc) + .into_iter() + .map(|entity| plane.place_entity(entity)) + .collect(); + } let base = dim.base(); let met = dim_metrics(dim, doc); @@ -1659,6 +1672,58 @@ mod tests { assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); } + /// The line segments EXPLODE turns the dimension into. + fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> { + let point = |v: Vector3| DVec3::new(v.x, v.y, v.z); + explode_dimension(dim, doc) + .into_iter() + .filter_map(|entity| match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + _ => None, + }) + .collect() + } + + /// EXPLODE of a linear dimension in a working plane off world Z leaves + /// its lines in that plane. Turned 90° about X, the dimension has to + /// explode into the same lines turned with it; its offset, now along Z, + /// used to be read in world XY only. + #[test] + fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() { + use crate::command::EntityTransform; + let doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + let flat = Dimension::Linear(d); + let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2); + let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc) + .into_iter() + .map(|(a, b)| (turn * a, turn * b)) + .collect(); + assert!(!expected.is_empty(), "the dimension explodes into no lines"); + + let mut turned = EntityType::Dimension(flat); + crate::scene::view::dispatch::apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: std::f64::consts::FRAC_PI_2, + }, + ); + let EntityType::Dimension(turned) = turned else { + panic!("dimension expected"); + }; + let lines = exploded_segments(&turned, &doc); + assert_eq!(lines.len(), expected.len(), "line count"); + for (index, (got, want)) in lines.iter().zip(&expected).enumerate() { + assert!( + got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9), + "line {index}: expected {want:?}, got {got:?}" + ); + } + } + // 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.