forked from 1b-agent/opencadstudio-upstream
491 lines
18 KiB
Rust
491 lines
18 KiB
Rust
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<f64> {
|
||
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<GripDef> {
|
||
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<PropSection> {
|
||
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<RenderEntity> {
|
||
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"
|
||
);
|
||
}
|
||
}
|