diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index be0cbea77..0cf40a5b2 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -30,13 +30,28 @@ fn to_render(ell: &Ellipse) -> RenderEntity { .map(crate::entities::curve::snap_from) .unwrap_or_default(); + let is_full = ell.is_full() + || (ell.end_parameter - ell.start_parameter).abs() >= std::f64::consts::TAU - 1e-9; + let (start_param, end_param) = if is_full { + (0.0, std::f64::consts::TAU) + } else { + let s = ell.start_parameter.rem_euclid(std::f64::consts::TAU); + let raw = ell.end_parameter - ell.start_parameter; + let sweep = if raw <= 0.0 { + raw.rem_euclid(std::f64::consts::TAU) + } else { + raw + }; + (s, s + sweep) + }; + let tangent = TangentGeom::PlanarEllipse { center: [ell.center.x, ell.center.y, ell.center.z], major_axis: [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], normal: [ell.normal.x, ell.normal.y, ell.normal.z], minor_axis_ratio: ell.minor_axis_ratio, - start_param: ell.start_parameter, - end_param: ell.end_parameter, + start_param, + end_param, }; // The points come from the entity's own kernel curve and angular policy. @@ -339,20 +354,58 @@ fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { } fn apply_transform(ell: &mut Ellipse, t: &EntityTransform) { - crate::scene::view::transform::apply_standard_entity_transform(ell, t, |entity, p1, p2| { - crate::scene::view::transform::reflect_xy_point( - &mut entity.center.x, - &mut entity.center.y, - p1, - p2, - ); - crate::scene::view::transform::reflect_xy_point( - &mut entity.major_axis.x, - &mut entity.major_axis.y, - p1, - p2, - ); - }); + use codec::types::{Transform, Vector3}; + let transform = match t { + EntityTransform::Translate(d) => { + ell.center = ell.center + Vector3::new(d.x, d.y, d.z); + return; + } + EntityTransform::Rotate { center, axis, angle_rad } => { + let axis_norm = Vector3::new(axis.x, axis.y, axis.z); + let axis_len = axis_norm.length(); + let axis_v = if axis_len > 1e-12 { axis_norm / axis_len } else { Vector3::UNIT_Z }; + Transform::from_translation(Vector3::new(-center.x, -center.y, -center.z)) + .then(&Transform::from_rotation(axis_v, *angle_rad)) + .then(&Transform::from_translation(Vector3::new(center.x, center.y, center.z))) + } + EntityTransform::Scale { center, factor } => { + let s = *factor; + Transform::from_scaling_with_origin( + Vector3::new(s, s, s), + Vector3::new(center.x, center.y, center.z), + ) + } + EntityTransform::Mirror { p1, p2, working_normal } => { + crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ) + } + EntityTransform::Affine(tr) => tr.clone(), + }; + + if let Some((c, m, n, ratio, s, e)) = crate::scene::view::transform::transform_ellipse_geometry( + ell.center, + ell.major_axis, + ell.normal, + ell.minor_axis_ratio, + ell.start_parameter, + ell.end_parameter, + &transform, + ) { + ell.center = c; + ell.major_axis = m; + ell.normal = n; + ell.minor_axis_ratio = ratio; + ell.start_parameter = s; + let is_full = (e - s - std::f64::consts::TAU).abs() < 1e-9; + ell.end_parameter = if is_full { + std::f64::consts::TAU + } else { + e.rem_euclid(std::f64::consts::TAU) + }; + } } impl RenderConvertible for Ellipse { @@ -487,4 +540,50 @@ mod grip_tests { "minor still 10 — no ballooning" ); } + + #[test] + fn test_apply_transform_negative_scale_and_rotation() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::FRAC_PI_2; + + let rot = EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::Z, + angle_rad: -std::f64::consts::FRAC_PI_4, + }; + apply_transform(&mut e, &rot); + + let scale = EntityTransform::Scale { + center: DVec3::ZERO, + factor: -1.0, + }; + apply_transform(&mut e, &scale); + + assert!((xy_len(&e) - 10.0).abs() < 1e-9); + assert!((e.minor_axis_ratio - 0.5).abs() < 1e-9); + assert!((e.normal.z - 1.0).abs() < 1e-9); + // Start parameter was 0, rotated by -45 then scaled by -1 (180 rotation) -> net effect + assert!(e.start_parameter >= 0.0 && e.start_parameter < std::f64::consts::TAU); + } + + #[test] + fn test_apply_transform_mirror() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.1; + e.end_parameter = 1.5; + + // Mirror across Y axis (p1=(0,0,0), p2=(0,1,0)) + let mirror = EntityTransform::Mirror { + p1: DVec3::ZERO, + p2: DVec3::Y, + working_normal: DVec3::Z, + }; + apply_transform(&mut e, &mirror); + + // Major axis should be reflected to (-10, 0, 0) + assert!((e.major_axis.x - (-10.0)).abs() < 1e-9); + // Normal should flip to -Z to preserve counter-clockwise traversal + assert!(e.normal.z < 0.0); + } } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 24abd04ab..cba0c18f1 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -3000,21 +3000,22 @@ fn transform_tangent( start_param, end_param, } => { - let c = t.apply(Vector3::new(center[0], center[1], center[2])); - let m = t.apply_rotation(Vector3::new(major_axis[0], major_axis[1], major_axis[2])); - let n = t.apply_rotation(Vector3::new(normal[0], normal[1], normal[2])); - let n_len = n.length(); - if n_len <= 1.0e-12 { - return None; - } - let n = n / n_len; + let (c, m, n, ratio, s, e) = crate::scene::view::transform::transform_ellipse_geometry( + Vector3::new(center[0], center[1], center[2]), + Vector3::new(major_axis[0], major_axis[1], major_axis[2]), + Vector3::new(normal[0], normal[1], normal[2]), + *minor_axis_ratio, + *start_param, + *end_param, + t, + )?; Some(TangentGeom::PlanarEllipse { center: [c.x, c.y, c.z], major_axis: [m.x, m.y, m.z], normal: [n.x, n.y, n.z], - minor_axis_ratio: *minor_axis_ratio, - start_param: *start_param, - end_param: *end_param, + minor_axis_ratio: ratio, + start_param: s, + end_param: e, }) } } @@ -3269,4 +3270,60 @@ mod compact_nested_tests { assert_eq!(wires.len(), 1); assert!(wires[0].render_instance.is_none()); } + + #[test] + fn test_transform_tangent_ellipse_arc_negative_scale_and_rotation() { + let ellipse_geom = TangentGeom::PlanarEllipse { + center: [10.0, 20.0, 0.0], + major_axis: [100.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: std::f64::consts::FRAC_PI_4, + end_param: 3.0 * std::f64::consts::FRAC_PI_4, + }; + + // Negative scale (-1, -1, 1), negative rotation -45 deg, distant translation (7000, 5000, 0) + let t = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -std::f64::consts::FRAC_PI_4)) + .then(&Transform::from_translation(Vector3::new(7000.0, 5000.0, 0.0))); + + let transformed = transform_tangent(&ellipse_geom, &t).expect("transform_tangent should succeed"); + let TangentGeom::PlanarEllipse { + center: c, + major_axis: m, + normal: n, + minor_axis_ratio: ratio, + start_param: s, + end_param: _e, + } = transformed else { + panic!("Expected PlanarEllipse"); + }; + + // Center must be transformed + let expected_c = t.apply(Vector3::new(10.0, 20.0, 0.0)); + assert!((c[0] - expected_c.x).abs() < 1e-9); + assert!((c[1] - expected_c.y).abs() < 1e-9); + + // Major axis length must be 100, ratio 0.5 + let m_vec = Vector3::new(m[0], m[1], m[2]); + assert!((m_vec.length() - 100.0).abs() < 1e-9); + assert!((ratio - 0.5).abs() < 1e-9); + assert!((n[2] - 1.0).abs() < 1e-9); + + // Start and end points on the curve must match mathematical transformation of original start/end + let orig_start_pt = Vector3::new(10.0, 20.0, 0.0) + + Vector3::new(100.0, 0.0, 0.0) * (std::f64::consts::FRAC_PI_4).cos() + + Vector3::new(0.0, 50.0, 0.0) * (std::f64::consts::FRAC_PI_4).sin(); + let expected_start_pt = t.apply(orig_start_pt); + + let new_v = Vector3::new(n[0], n[1], n[2]).cross(&m_vec) * ratio; + let actual_start_pt = Vector3::new(c[0], c[1], c[2]) + + m_vec * s.cos() + + new_v * s.sin(); + + assert!( + (actual_start_pt - expected_start_pt).length() < 1e-9, + "Start point mismatch: expected {expected_start_pt:?}, got {actual_start_pt:?}" + ); + } } diff --git a/src/scene/view/transform.rs b/src/scene/view/transform.rs index d9ed5fc90..151995ba9 100644 --- a/src/scene/view/transform.rs +++ b/src/scene/view/transform.rs @@ -120,6 +120,93 @@ pub fn reflect_xy_point(x: &mut f64, y: &mut f64, p1: DVec3, p2: DVec3) { *y = p1.y as f64 + my; } +/// Fully transforms ellipse / elliptical arc geometry under any 3D affine transform +/// (including negative scales, reflections, negative rotations, non-uniform scales, and translation). +/// +/// Returns `(new_center, new_major_axis, new_normal, new_minor_axis_ratio, new_start_param, new_end_param)` +/// where `new_major_axis` is guaranteed to be orthogonal to `new_normal`, its length is >= semi-minor axis length, +/// and `new_minor_axis_ratio <= 1.0`. +pub fn transform_ellipse_geometry( + center: Vector3, + major_axis: Vector3, + normal: Vector3, + minor_axis_ratio: f64, + start_param: f64, + end_param: f64, + transform: &Transform, +) -> Option<(Vector3, Vector3, Vector3, f64, f64, f64)> { + let norm_len = normal.length(); + if norm_len <= 1e-12 || !norm_len.is_finite() { + return None; + } + let norm = normal / norm_len; + let m = major_axis; + let m_len = m.length(); + if m_len <= 1e-12 || !m_len.is_finite() { + return None; + } + let ratio = minor_axis_ratio.clamp(1e-12, 1.0); + // V = (N x M) * ratio + let v = norm.cross(&m) * ratio; + + let c_prime = transform.apply(center); + let m0 = transform.apply_rotation(m); + let v0 = transform.apply_rotation(v); + + let m0_len_sq = m0.length_squared(); + let v0_len_sq = v0.length_squared(); + if m0_len_sq <= 1e-24 || v0_len_sq <= 1e-24 || !m0_len_sq.is_finite() || !v0_len_sq.is_finite() { + return None; + } + + // Extremum angle theta to find the principal axes of the transformed ellipse + let dot = m0.dot(&v0); + let diff = m0_len_sq - v0_len_sq; + let theta = 0.5 * (2.0 * dot).atan2(diff); + + let (sin_t, cos_t) = theta.sin_cos(); + let m_new = m0 * cos_t + v0 * sin_t; + let v_new = m0 * (-sin_t) + v0 * cos_t; + + let a = m_new.length(); + let b = v_new.length(); + if a <= 1e-12 || !a.is_finite() { + return None; + } + + let cross = m_new.cross(&v_new); + let cross_len = cross.length(); + if cross_len <= 1e-12 || !cross_len.is_finite() { + return None; + } + let n_new = cross / cross_len; + let new_ratio = (b / a).clamp(1e-12, 1.0); + + // Sweep and parameters + use std::f64::consts::TAU; + let raw = end_param - start_param; + let is_full = (raw - TAU).abs() < 1e-9 + || (raw.abs() >= TAU - 1e-9) + || (raw.rem_euclid(TAU).abs() < 1e-9 && raw.abs() > 1e-9); + + let (new_start, new_end) = if is_full { + (0.0, TAU) + } else { + let sweep = if raw <= 0.0 { + let s = raw.rem_euclid(TAU); + if s <= 1e-9 { TAU } else { s } + } else if raw > TAU { + TAU + } else { + raw + }; + let s = (start_param - theta).rem_euclid(TAU); + (s, s + sweep) + }; + + Some((c_prime, m_new, n_new, new_ratio, new_start, new_end)) +} + /// DXF arbitrary-axis algorithm — returns the OCS X and Y basis vectors in WCS /// for a given entity normal vector. /// @@ -271,3 +358,154 @@ mod ocs_axes_142 { assert!(p.0.abs()+p.1.abs()+p.2.abs() > 1.0, "point collapsed: {:?}", p); } } + +#[cfg(test)] +mod ellipse_transform_tests { + use super::*; + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + + fn sample_point(center: Vector3, major: Vector3, normal: Vector3, ratio: f64, t: f64) -> Vector3 { + let u = major; + let v = normal.normalize().cross(&major) * ratio; + center + u * t.cos() + v * t.sin() + } + + #[test] + fn negative_rotation_negative_scale_distant_origin() { + // Original ellipse arc at distant center (7000, 5000, 0) + let center = Vector3::new(7000.0, 5000.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; + let start_param = FRAC_PI_4; + let end_param = 3.0 * FRAC_PI_4; + + // Transform: non-uniform negative scale (-1.0, -1.0, 1.0), rotation -45 deg, translation (100, 200, 0) + let rot_rad = -FRAC_PI_4; + let transform = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rot_rad)) + .then(&Transform::from_translation(Vector3::new(100.0, 200.0, 0.0))); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0 && r > 0.0, "ratio must be in (0, 1], got {r}"); + assert!(m.length() >= m.length() * r); + + // Verify that start, end, and intermediate points match exactly + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn axis_swapping_under_non_uniform_negative_scale() { + // When scale along Y is much larger, the minor axis becomes the major axis + let center = Vector3::new(0.0, 0.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; // initial minor length is 5.0 + let start_param = 0.1; + let end_param = 2.0; + + // Scale X by -1, Scale Y by -3: transformed major length is 10, transformed minor length is 15. + // So axes must swap! + let transform = Transform::from_scaling(Vector3::new(-1.0, -3.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -FRAC_PI_2)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0, "ratio must be <= 1.0 after axis swap, got {r}"); + assert!((r - (10.0 / 15.0)).abs() < 1e-9); + + // Verify points match + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn reflection_handedness_flip_arc_preservation() { + // Reflection across Y axis (scale X by -1, Y by 1) + let center = Vector3::new(50.0, 60.0, 0.0); + let major = Vector3::new(8.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.75; + let start_param = 0.2; + let end_param = 1.8; + + let transform = Transform::from_scaling(Vector3::new(-1.0, 1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -0.6)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + // Normal must be inverted in Z due to reflection + assert!(n.z < 0.0, "normal Z should be flipped, got {}", n.z); + + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } +}