use acadrust::entities::Ellipse; use cadkernel::geom2d::{ Curve as KernelCurve, EllipseArc as KernelEllipseArc, Tolerance, Vec2 as KernelVec2, }; use cadkernel::space::PlanarCurve; use crate::entities::curve::CurveSnap; use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ center_grip, edit_prop as edit, format_area, format_length, oriented_triangle_grip, parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::TangentGeom; fn to_render(ell: &Ellipse) -> RenderEntity { // ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and // `major_axis` (code 11) are world coordinates already — unlike ARC / // CIRCLE, whose centers are OCS. The converter knows that; this used to // run both through the arbitrary-axis OCS, which misplaced any ellipse // whose normal isn't Z-up — e.g. the (0,0,-1) result of a mirrored-block // explode. let curve = crate::entities::curve::ellipse_curve(ell); let snap = curve .as_ref() .map(crate::entities::curve::snap_from) .unwrap_or_default(); 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, }; // The points come from the entity's own kernel curve and angular policy. // // What does not change is the shape of the object. EXTRUDE, REVOLVE and // SWEEP read their profile out of `Contour` / `Curve` and have no arm for // a bare point list, so an ellipse handed over as `Lines` would stop // being usable as either. let empty = |snap: CurveSnap| RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(Vec::new()), snap_pts: snap.snap_pts, tangent_geoms: vec![tangent.clone()], key_vertices: vec![], fill_tris: vec![], }; let Some(planar) = curve else { return empty(snap); }; let points = crate::entities::curve::curve_points(&planar); if points.len() < 2 { return empty(snap); } let object = RenderObject::Lines(points); RenderEntity { pick_tris: Vec::new(), object, snap_pts: snap.snap_pts, tangent_geoms: vec![tangent], key_vertices: vec![], fill_tris: vec![], } } fn ellipse_curves(ell: &Ellipse) -> Option<(PlanarCurve, PlanarCurve)> { let arc = crate::entities::curve::ellipse_curve(ell)?; let KernelCurve::Ellipse(piece) = &arc.curve else { return None; }; let full = PlanarCurve::new( arc.plane, KernelCurve::Ellipse(KernelEllipseArc::full(piece.ellipse)), ); Some((arc, full)) } fn is_closed(ell: &Ellipse) -> bool { crate::entities::curve::ellipse_curve(ell).is_some_and(|curve| curve.curve.is_closed()) } fn parameter_at_point(ell: &Ellipse, point: glam::DVec3) -> Option { let (_, full) = ellipse_curves(ell)?; Some( (full.parameter_at(point.to_array())? * std::f64::consts::TAU) .rem_euclid(std::f64::consts::TAU), ) } fn grips(ell: &Ellipse) -> Vec { let fallback = glam::DVec3::new(ell.center.x, ell.center.y, ell.center.z); let Some((arc, full)) = ellipse_curves(ell) else { return vec![center_grip(0, fallback)]; }; let KernelCurve::Ellipse(piece) = &full.curve else { return vec![center_grip(0, fallback)]; }; let ctr = glam::DVec3::from_array(full.plane.point_at(piece.ellipse.centre)); let axes = [0.0, 0.25, 0.5, 0.75].map(|t| glam::DVec3::from_array(full.point_at(t))); let mut grips = vec![ center_grip(0, ctr), square_grip(1, axes[0]), square_grip(2, axes[1]), square_grip(3, axes[2]), square_grip(4, axes[3]), ]; if !arc.curve.is_closed() { let start = glam::DVec3::from_array(arc.point_at(0.0)); let end = glam::DVec3::from_array(arc.point_at(1.0)); let start_outward = -glam::DVec3::from_array(arc.tangent_at(0.0)); let end_outward = glam::DVec3::from_array(arc.tangent_at(1.0)); grips.push(oriented_triangle_grip(5, start, start_outward)); grips.push(oriented_triangle_grip(6, end, end_outward)); } grips } fn properties(ell: &Ellipse) -> Vec { use crate::entities::traits::MassPropsCalc; let r_major = ell.major_axis_length(); let r_minor = ell.minor_axis_length(); // Axis frame in WCS: major-axis unit vector `u`, minor direction `v = normal × u`. let u = glam::DVec3::new(ell.major_axis.x, ell.major_axis.y, ell.major_axis.z); let u = if r_major > 1e-12 { u / r_major } else { glam::DVec3::X }; let n = glam::DVec3::new(ell.normal.x, ell.normal.y, ell.normal.z); let v = n.cross(u); // Parametric points in WCS: center + r_major·cos(t)·u + r_minor·sin(t)·v. let center = glam::DVec3::new(ell.center.x, ell.center.y, ell.center.z); let pt_at = |t: f64| center + u * (r_major * t.cos()) + v * (r_minor * t.sin()); let start = pt_at(ell.start_parameter); let end = pt_at(ell.end_parameter); // Axis vectors (center → axis endpoint) in WCS. let major_vec = u * r_major; let minor_vec = v * r_minor; let start_angle = ell.start_parameter.to_degrees().rem_euclid(360.0); let end_angle = if is_closed(ell) { 360.0 } else { ell.end_parameter.to_degrees().rem_euclid(360.0) }; let props = ell.mass_props(); vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ ro(t!("Start X").as_ref(), "start_x", format_length(start.x)), ro(t!("Start Y").as_ref(), "start_y", format_length(start.y)), ro(t!("Start Z").as_ref(), "start_z", format_length(start.z)), edit(t!("Center X").as_ref(), "center_x", ell.center.x), edit(t!("Center Y").as_ref(), "center_y", ell.center.y), edit(t!("Center Z").as_ref(), "center_z", ell.center.z), ro(t!("End X").as_ref(), "end_x", format_length(end.x)), ro(t!("End Y").as_ref(), "end_y", format_length(end.y)), ro(t!("End Z").as_ref(), "end_z", format_length(end.z)), edit(t!("Major radius").as_ref(), "major_r", r_major), edit(t!("Minor radius").as_ref(), "minor_r", r_minor), edit(t!("Radius ratio").as_ref(), "ratio", ell.minor_axis_ratio), edit(t!("Start angle").as_ref(), "start_angle", start_angle), edit(t!("End angle").as_ref(), "end_angle", end_angle), ro(t!("Major axis vector X").as_ref(), "major_x", format_length(major_vec.x)), ro(t!("Major axis vector Y").as_ref(), "major_y", format_length(major_vec.y)), ro(t!("Major axis vector Z").as_ref(), "major_z", format_length(major_vec.z)), ro(t!("Minor axis vector X").as_ref(), "minor_x", format_length(minor_vec.x)), ro(t!("Minor axis vector Y").as_ref(), "minor_y", format_length(minor_vec.y)), ro(t!("Minor axis vector Z").as_ref(), "minor_z", format_length(minor_vec.z)), ro(t!("Area").as_ref(), "area", format_area(props.area)), ro(t!("Start parameter").as_ref(), "start_param", format!("{:.4}", ell.start_parameter)), ro(t!("End parameter").as_ref(), "end_param", format!("{:.4}", ell.end_parameter)), ro(t!("Length").as_ref(), "length", format_length(props.perimeter)), edit(t!("Normal X").as_ref(), "normal_x", ell.normal.x), edit(t!("Normal Y").as_ref(), "normal_y", ell.normal.y), edit(t!("Normal Z").as_ref(), "normal_z", ell.normal.z), ], }] } fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) { let Some(v) = parse_f64(value) else { return; }; match field { "center_x" => ell.center.x = v, "center_y" => ell.center.y = v, "center_z" => ell.center.z = v, "major_r" => { let cur = ell.major_axis_length(); if cur > 1e-12 && v > 0.0 { let s = v / cur; ell.major_axis.x *= s; ell.major_axis.y *= s; ell.major_axis.z *= s; } } "minor_r" => { let major = ell.major_axis_length(); if major > 1e-12 && v > 0.0 { ell.minor_axis_ratio = v / major; } } "ratio" if v > 0.0 => ell.minor_axis_ratio = v, "start_angle" => ell.start_parameter = v.to_radians(), "end_angle" => ell.end_parameter = v.to_radians(), "normal_x" | "normal_y" | "normal_z" => { let mut normal = [ell.normal.x, ell.normal.y, ell.normal.z]; match field { "normal_x" => normal[0] = v, "normal_y" => normal[1] = v, "normal_z" => normal[2] = v, _ => {} } let Some(normal) = cadkernel::space::Vec3::from(normal).normalize() else { return; }; let Some(major) = cadkernel::space::reorient_axis_to_plane( [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], normal.to_array(), ) else { return; }; ell.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); ell.major_axis = acadrust::types::Vector3::new(major[0], major[1], major[2]); } _ => {} } } fn apply_axis_grip(ell: &mut Ellipse, grip_id: usize, point: glam::DVec3) { let Some((_, full)) = ellipse_curves(ell) else { return; }; let KernelCurve::Ellipse(piece) = &full.curve else { return; }; let Some(projected) = full.plane.project(point.to_array()) else { return; }; let center = KernelVec2::from(piece.ellipse.centre); let mut dragged = KernelVec2::from(projected) - center; let tolerance = Tolerance::default().linear(); if matches!(grip_id, 1 | 3) { if grip_id == 3 { dragged = -dragged; } if dragged.length() <= tolerance { return; } let axis = full.plane.vector_at(dragged.to_array()); ell.major_axis.x = axis[0]; ell.major_axis.y = axis[1]; ell.major_axis.z = axis[2]; return; } if grip_id == 4 { dragged = -dragged; } let major_dir = KernelVec2::from(piece.ellipse.major_axis); let minor_dir = major_dir.perpendicular(); let major_len = piece.ellipse.major_radius.abs(); let minor_len = piece.ellipse.minor_radius.abs(); if major_len <= tolerance || minor_len <= tolerance { return; } let along_major = dragged.dot(major_dir).abs(); let along_minor = dragged.dot(minor_dir).abs(); let (drag_dir, dragged_len, fixed_dir, fixed_len) = if along_minor >= along_major { (minor_dir, along_minor, major_dir, major_len) } else { (major_dir, along_major, minor_dir, minor_len) }; if fixed_len <= tolerance { return; } let (axis, ratio) = if dragged_len >= fixed_len { ( drag_dir * dragged_len, (fixed_len / dragged_len).clamp(0.001, 1.0), ) } else { ( fixed_dir * fixed_len, (dragged_len / fixed_len).clamp(0.001, 1.0), ) }; let axis = full.plane.vector_at(axis.to_array()); ell.major_axis.x = axis[0]; ell.major_axis.y = axis[1]; ell.major_axis.z = axis[2]; ell.minor_axis_ratio = ratio; } fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { match (grip_id, apply) { (0, GripApply::Translate(d)) => { ell.center.x += d.x; ell.center.y += d.y; ell.center.z += d.z; } (0, GripApply::Absolute(p)) => { ell.center.x = p.x; ell.center.y = p.y; ell.center.z = p.z; } (1..=4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p), (5 | 6, GripApply::Absolute(p)) => { let Some(parameter) = parameter_at_point(ell, p) else { return; }; if grip_id == 5 { ell.start_parameter = parameter; } else { ell.end_parameter = parameter; } } _ => {} } } 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, ); }); } impl RenderConvertible for Ellipse { fn to_render(&self, _document: &acadrust::CadDocument) -> Option { Some(to_render(self)) } } crate::impl_entity_basics!(Ellipse); impl crate::entities::traits::MassPropsCalc for acadrust::entities::Ellipse { fn mass_props(&self) -> crate::entities::traits::MassProps { use std::f64::consts::{PI, TAU}; let e = self; let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); let b = a * e.minor_axis_ratio; let t0 = e.start_parameter; let t1 = { let mut t = e.end_parameter; if t <= t0 { t += TAU; } t }; let span = t1 - t0; let is_full = (span - TAU).abs() < 1e-6; let area = if is_full { PI * a * b } else { // Sector area of ellipse approximated via 256-pt integration let n = 256usize; let mut s = 0.0f64; for k in 0..n { let t = t0 + span * (k as f64 / n as f64); let tp = t0 + span * ((k + 1) as f64 / n as f64); let nx = e.major_axis.x / a; let ny = e.major_axis.y / a; let x0 = a * t.cos() * nx - b * t.sin() * ny; let y0 = a * t.cos() * ny + b * t.sin() * nx; let x1 = a * tp.cos() * nx - b * tp.sin() * ny; let y1 = a * tp.cos() * ny + b * tp.sin() * nx; s += x0 * y1 - x1 * y0; } (s / 2.0).abs() }; // Arc length via 256-pt numerical integration let nx = e.major_axis.x / a.max(1e-12); let ny = e.major_axis.y / a.max(1e-12); let perimeter = { let n = 256usize; let mut len = 0.0f64; for k in 0..n { let t = t0 + span * (k as f64 / n as f64); let tp = t0 + span * ((k + 1) as f64 / n as f64); let x0 = e.center.x + a * t.cos() * nx - b * t.sin() * ny; let y0 = e.center.y + a * t.cos() * ny + b * t.sin() * nx; let x1 = e.center.x + a * tp.cos() * nx - b * tp.sin() * ny; let y1 = e.center.y + a * tp.cos() * ny + b * tp.sin() * nx; len += (x1 - x0).hypot(y1 - y0); } len }; crate::entities::traits::MassProps { area, perimeter, cx: e.center.x, cy: e.center.y, } } } #[cfg(test)] mod grip_tests { use super::*; use glam::DVec3; fn ell(major_x: f64, ratio: f64) -> Ellipse { let mut e = Ellipse::default(); e.center.x = 0.0; e.center.y = 0.0; e.center.z = 0.0; e.major_axis.x = major_x; e.major_axis.y = 0.0; e.major_axis.z = 0.0; e.minor_axis_ratio = ratio; e } fn xy_len(v: &acadrust::entities::Ellipse) -> f64 { (v.major_axis.x * v.major_axis.x + v.major_axis.y * v.major_axis.y).sqrt() } #[test] fn editing_normal_reorients_major_axis_in_kernel() { let mut e = ell(10.0, 0.5); apply_geom_prop(&mut e, "normal_x", "1"); let normal = DVec3::new(e.normal.x, e.normal.y, e.normal.z); let major = DVec3::new(e.major_axis.x, e.major_axis.y, e.major_axis.z); assert!((normal.length() - 1.0).abs() < 1.0e-12); assert!((major.length() - 10.0).abs() < 1.0e-9); assert!(normal.dot(major).abs() < 1.0e-9); } #[test] fn minor_grip_below_major_updates_ratio_only() { let mut e = ell(10.0, 0.5); apply_grip(&mut e, 2, GripApply::Absolute(DVec3::new(0.0, 8.0, 0.0))); assert!((xy_len(&e) - 10.0).abs() < 1e-9, "major axis unchanged"); assert!((e.minor_axis_ratio - 0.8).abs() < 1e-9, "ratio = 8/10"); } #[test] fn minor_grip_past_major_swaps_without_ballooning() { // major=10 (+X), minor=5 (+Y). Drag the minor grip to (0,11), past major. let mut e = ell(10.0, 0.5); apply_grip(&mut e, 2, GripApply::Absolute(DVec3::new(0.0, 11.0, 0.0))); assert!((xy_len(&e) - 11.0).abs() < 1e-9, "major follows the drag to 11"); assert!(e.major_axis.x.abs() < 1e-9, "major points +Y after the swap"); assert!( (xy_len(&e) * e.minor_axis_ratio - 10.0).abs() < 1e-9, "minor holds the old major length (10)" ); // Keep dragging along +Y: the perpendicular axis must stay 10, not // balloon, and the major must not flip 90° each frame (the reported bug). apply_grip(&mut e, 2, GripApply::Absolute(DVec3::new(0.0, 13.0, 0.0))); assert!((xy_len(&e) - 13.0).abs() < 1e-9, "major grows to 13"); assert!(e.major_axis.x.abs() < 1e-9, "major stays +Y (no flip)"); assert!( (xy_len(&e) * e.minor_axis_ratio - 10.0).abs() < 1e-6, "minor still 10 — no ballooning" ); } }