forked from 1b-agent/opencadstudio-upstream
feat(ellipse): add complete grip editing
This commit is contained in:
commit
0f250bb485
5 files changed
+193
-92
No files matched your search
+13
-1
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user