feat(ellipse): add complete grip editing

This commit is contained in:
Hakan Seven committed 2026-08-19 23:48:13 +03:00
commit 0f250bb485
5 files changed
+193 -92

No files matched your search

+13 -1
View File
@@ -462,7 +462,7 @@ pub fn rectangle_grip(id: usize, world: glam::DVec3, dir: [f32; 2]) -> GripDef {
world,
is_midpoint: true,
shape: GripShape::Rectangle,
dir: Some(dir),
dir: Some(glam::DVec3::new(dir[0] as f64, dir[1] as f64, 0.0)),
axis: None,
}
}
@@ -479,6 +479,18 @@ pub fn triangle_grip(id: usize, world: glam::DVec3) -> GripDef {
}
}
/// Triangle grip pointing along a world-space direction.
pub fn oriented_triangle_grip(id: usize, world: glam::DVec3, dir: glam::DVec3) -> GripDef {
GripDef {
id,
world,
is_midpoint: false,
shape: GripShape::Triangle,
dir: Some(dir),
axis: None,
}
}
/// Editable ANGLE row: displays via AUNITS/AUPREC. Angle rows used the
/// LINEAR formatter, so LUNITS=Architectural showed a block rotation as
/// feet-and-inches and the string wouldn't parse back (#297). Value in
+126 -72
View File
@@ -1,11 +1,16 @@
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, parse_f64, ro_prop as ro, square_grip,
center_grip, edit_prop as edit, 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};
@@ -58,33 +63,56 @@ fn to_render(ell: &Ellipse) -> RenderEntity {
}
}
/// A curve through every one of `points` in order.
///
/// Degree one, so the control polygon *is* the curve — the same thing the
/// hand-built sampling produced, without restating how a B-spline is put
/// together at each site.
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 ctr = glam::DVec3::new(ell.center.x, ell.center.y, ell.center.z);
let maj = glam::DVec3::new(
ell.center.x + ell.major_axis.x,
ell.center.y + ell.major_axis.y,
ell.center.z + ell.major_axis.z,
);
let major_xy =
((ell.major_axis.x * ell.major_axis.x + ell.major_axis.y * ell.major_axis.y) as f64).sqrt();
let (px, py) = if major_xy > 1e-10 {
let s = ell.major_axis_length() * ell.minor_axis_ratio / major_xy;
(-ell.major_axis.y * s, ell.major_axis.x * s)
} else {
(0.0, ell.major_axis_length() * ell.minor_axis_ratio)
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 min = glam::DVec3::new(ell.center.x + px, ell.center.y + py, ell.center.z);
vec![
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, maj),
square_grip(2, min),
]
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> {
@@ -114,9 +142,7 @@ fn properties(ell: &Ellipse) -> Vec<PropSection> {
let minor_vec = v * r_minor;
let start_angle = ell.start_parameter.to_degrees().rem_euclid(360.0);
let end_angle = if crate::entities::curve::ellipse_curve(ell)
.is_some_and(|curve| curve.curve.is_closed())
{
let end_angle = if is_closed(ell) {
360.0
} else {
ell.end_parameter.to_degrees().rem_euclid(360.0)
@@ -188,6 +214,73 @@ fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) {
}
}
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)) => {
@@ -200,54 +293,15 @@ fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) {
ell.center.y = p.y as f64;
ell.center.z = p.z as f64;
}
(1, GripApply::Absolute(p)) => {
ell.major_axis.x = p.x as f64 - ell.center.x;
ell.major_axis.y = p.y as f64 - ell.center.y;
ell.major_axis.z = p.z as f64 - ell.center.z;
}
(2, GripApply::Absolute(p)) => {
// Minor-axis grip. The two axes are always perpendicular, so this
// grip stretches one of them along its fixed direction while the
// other stays put. Work in the ellipse's view plane (XY).
let mx = ell.major_axis.x;
let my = ell.major_axis.y;
let major_len = (mx * mx + my * my).sqrt();
if major_len <= 1e-10 {
(1 | 2 | 3 | 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;
}
let major_unit = (mx / major_len, my / major_len);
let minor_unit = (-major_unit.1, major_unit.0);
let minor_len = major_len * ell.minor_axis_ratio;
let dx = p.x as f64 - ell.center.x;
let dy = p.y as f64 - ell.center.y;
// Which stored axis is actually under the cursor? Below a circle it
// is the minor direction; once the drag pushes the minor past the
// major and the axes swap, the grabbed endpoint sits on the major
// direction. Decide by cursor alignment — NOT by recomputing a perp
// from the just-swapped major every frame — so a continuous drag
// stays stable instead of flipping the major 90° each move (which
// made both axes balloon once the minor reached the major). The
// perpendicular (non-dragged) axis keeps its length; the longer axis
// is stored as the major so the ratio stays <= 1 per the file format.
let along_major = (dx * major_unit.0 + dy * major_unit.1).abs();
let along_minor = (dx * minor_unit.0 + dy * minor_unit.1).abs();
let (drag_dir, dragged_len, fixed_dir, fixed_len) = if along_minor >= along_major {
(minor_unit, along_minor, major_unit, major_len)
} else {
(major_unit, along_major, minor_unit, minor_len)
};
if fixed_len <= 1e-10 {
return;
}
if dragged_len >= fixed_len {
ell.major_axis.x = drag_dir.0 * dragged_len;
ell.major_axis.y = drag_dir.1 * dragged_len;
ell.minor_axis_ratio = (fixed_len / dragged_len).clamp(0.001, 1.0);
if grip_id == 5 {
ell.start_parameter = parameter;
} else {
ell.major_axis.x = fixed_dir.0 * fixed_len;
ell.major_axis.y = fixed_dir.1 * fixed_len;
ell.minor_axis_ratio = (dragged_len / fixed_len).clamp(0.001, 1.0);
ell.end_parameter = parameter;
}
}
_ => {}
+2 -4
View File
@@ -101,10 +101,8 @@ pub struct GripDef {
pub is_midpoint: bool,
/// Visual marker shape for the grip.
pub shape: GripShape,
/// World-XY direction vector used to orient a `Rectangle` grip
/// along its segment. `None` for shapes that don't need rotation
/// (Square, Triangle in non-directional contexts).
pub dir: Option<[f32; 2]>,
/// World-space marker direction, projected with the grip position.
pub dir: Option<glam::DVec3>,
/// World-space axis that constrains this grip's drag.
pub axis: Option<glam::DVec3>,
}
+37 -9
View File
@@ -78,6 +78,10 @@ impl GripEdit {
// ── Screen-space helpers ───────────────────────────────────────────────────
fn marker_direction(from: Vec2, to: Vec2) -> [f32; 2] {
[to.x - from.x, from.y - to.y]
}
/// Project a slice of `GripDef`s to screen space.
/// Returns `(grip_id, screen_pos, is_midpoint, shape)` for each grip.
pub fn grips_to_screen(
@@ -91,11 +95,18 @@ pub fn grips_to_screen(
// Project from the f64 grip position via the relative-to-eye path so
// the grip stays glued to the wire at UTM-scale coordinates (an
// `as_vec3` cast first would quantize it ~0.5 m off at high zoom).
let screen = match camera.project(g.world, bounds) {
let projected = camera.project(g.world, bounds);
let screen = match projected {
Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y),
None => Point::new(f32::NAN, f32::NAN),
};
(g.id, screen, g.is_midpoint, g.shape, g.dir)
let dir = g.dir.and_then(|dir| {
Some(marker_direction(
projected?,
camera.project(g.world + dir, bounds)?,
))
});
(g.id, screen, g.is_midpoint, g.shape, dir)
})
.collect()
}
@@ -110,14 +121,24 @@ pub fn grips_to_screen_paper(
half_h: f32,
bounds: Rectangle,
) -> Vec<(usize, Point, bool, GripShape, Option<[f32; 2]>)> {
let project = |world: DVec3| {
Vec2::new(
(world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width,
(ty + half_h - world.y as f32) / (2.0 * half_h) * bounds.height,
)
};
grips
.iter()
.map(|g| {
let screen = Point::new(
(g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width,
(ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height,
);
(g.id, screen, g.is_midpoint, g.shape, g.dir)
let projected = project(g.world);
let screen = Point::new(projected.x, projected.y);
let dir = g.dir.map(|dir| {
[
dir.x as f32 / (2.0 * half_w) * bounds.width,
dir.y as f32 / (2.0 * half_h) * bounds.height,
]
});
(g.id, screen, g.is_midpoint, g.shape, dir)
})
.collect()
}
@@ -207,11 +228,18 @@ pub fn grips_to_screen_rte(
grips
.iter()
.map(|g| {
let screen = match project_rte(g.world, view_rot, eye, bounds) {
let projected = project_rte(g.world, view_rot, eye, bounds);
let screen = match projected {
Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y),
None => Point::new(f32::NAN, f32::NAN),
};
(g.id, screen, g.is_midpoint, g.shape, g.dir)
let dir = g.dir.and_then(|dir| {
Some(marker_direction(
projected?,
project_rte(g.world + dir, view_rot, eye, bounds)?,
))
});
(g.id, screen, g.is_midpoint, g.shape, dir)
})
.collect()
}
+15 -6
View File
@@ -81,9 +81,7 @@ pub struct GripMarker {
pub is_hot: bool,
/// True → pointer is over this grip (drawn with the hover fill).
pub is_hovered: bool,
/// World-XY direction vector — only consumed by the `Rectangle`
/// shape to orient the box along its segment. `None` for grips
/// that don't need rotation.
/// Screen-plane direction with positive Y pointing up.
pub dir: Option<[f32; 2]>,
}
@@ -386,9 +384,20 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme)
})
}
GripShape::Triangle => canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - h));
b.line_to(Point::new(sp.x + h, sp.y + h));
b.line_to(Point::new(sp.x - h, sp.y + h));
let (forward_x, forward_y) = match grip.dir {
Some([dx, dy]) if (dx * dx + dy * dy) > 1e-12 => {
let length = (dx * dx + dy * dy).sqrt();
(dx / length, -dy / length)
}
_ => (0.0, -1.0),
};
let side_x = -forward_y;
let side_y = forward_x;
let tip = Point::new(sp.x + forward_x * h, sp.y + forward_y * h);
let base = Point::new(sp.x - forward_x * h, sp.y - forward_y * h);
b.move_to(tip);
b.line_to(Point::new(base.x + side_x * h, base.y + side_y * h));
b.line_to(Point::new(base.x - side_x * h, base.y - side_y * h));
b.close();
}),
GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h),