feat(grips): improve direct editing

Refit arcs from fixed control points and highlight hovered grips.
Let Lengthen preview from the cursor or accept a typed distance.
This commit is contained in:
Hakan Seven 2026-08-04 16:27:03 +03:00
commit 539d83de47
10 changed files with 421 additions and 59 deletions

View file

@ -4,7 +4,8 @@ use truck_modeling::{builder, Point3};
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro,
square_grip,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
@ -103,26 +104,114 @@ fn to_truck(arc: &Arc) -> TruckEntity {
}
}
fn angle_span(start: f32, end: f32) -> f32 {
let mut span = end - start;
if span < 0.0 {
span += std::f32::consts::TAU;
fn control_points(arc: &Arc) -> [glam::DVec3; 3] {
let center = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z);
let sweep = (arc.end_angle - arc.start_angle).rem_euclid(TAU);
let point = |angle: f64| {
center
+ glam::DVec3::new(
arc.radius * angle.cos(),
arc.radius * angle.sin(),
0.0,
)
};
[
point(arc.start_angle),
point(arc.start_angle + sweep * 0.5),
point(arc.end_angle),
]
}
fn circumcircle(
a: glam::DVec3,
b: glam::DVec3,
c: glam::DVec3,
) -> Option<(glam::DVec3, f64)> {
// Work relative to the first point so large drawing coordinates do not
// lose the small differences that define the circle.
let ab = b - a;
let ac = c - a;
let bc = c - b;
let scale2 = ab
.length_squared()
.max(ac.length_squared())
.max(bc.length_squared());
if scale2 < 1.0e-18 {
return None;
}
span
let det = 2.0 * (ab.x * ac.y - ab.y * ac.x);
if det.abs() <= scale2 * 1.0e-12 {
return None;
}
let ab2 = ab.x * ab.x + ab.y * ab.y;
let ac2 = ac.x * ac.x + ac.y * ac.y;
let center = a
+ glam::DVec3::new(
(ab2 * ac.y - ac2 * ab.y) / det,
(ab.x * ac2 - ac.x * ab2) / det,
0.0,
);
let radius = center.distance(a);
radius.is_finite().then_some((center, radius))
}
pub(crate) fn refit_grips(
arc: &mut Arc,
original: &Arc,
edits: &[(usize, glam::DVec3)],
) -> bool {
let mut points = control_points(original);
let mut changed = false;
for &(grip_id, point) in edits {
let index = match grip_id {
1 => 0,
2 => 2,
3 => 1,
_ => continue,
};
points[index] = point;
changed = true;
}
if !changed {
return false;
}
let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else {
return false;
};
if radius <= 1.0e-9 {
return false;
}
let start = (points[0].y - center.y).atan2(points[0].x - center.x);
let middle = (points[1].y - center.y).atan2(points[1].x - center.x);
let end = (points[2].y - center.y).atan2(points[2].x - center.x);
let sweep = (end - start).rem_euclid(TAU);
let middle_sweep = (middle - start).rem_euclid(TAU);
// Crossing the two fixed points makes the three-point definition
// degenerate before it reverses. Keep the last valid preview instead of
// swapping the start and end grip identities under the cursor.
if sweep <= 1.0e-9 || middle_sweep > sweep + 1.0e-9 {
return false;
}
arc.center.x = center.x;
arc.center.y = center.y;
arc.center.z = original.center.z;
arc.radius = radius;
arc.start_angle = start;
arc.end_angle = end;
true
}
fn grips(arc: &Arc) -> Vec<GripDef> {
let ctr = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z);
let r = arc.radius;
let sa = arc.start_angle as f32;
let ea = arc.end_angle as f32;
let ma = (sa + angle_span(sa, ea) * 0.5) as f64;
let (sa, ea) = (arc.start_angle, arc.end_angle);
let [start, middle, end] = control_points(arc);
vec![
center_grip(0, ctr),
square_grip(1, ctr + glam::DVec3::new(r * sa.cos(), r * sa.sin(), 0.0)),
square_grip(2, ctr + glam::DVec3::new(r * ea.cos(), r * ea.sin(), 0.0)),
center_grip(3, ctr + glam::DVec3::new(r * ma.cos(), r * ma.sin(), 0.0)),
square_grip(1, start),
square_grip(2, end),
square_grip(3, middle),
]
}
@ -204,31 +293,9 @@ fn apply_grip(arc: &mut Arc, grip_id: usize, apply: GripApply) {
arc.center.y = p.y;
arc.center.z = p.z;
}
(1, GripApply::Absolute(p)) => {
let dx = p.x - arc.center.x;
let dy = p.y - arc.center.y;
arc.start_angle = dy.atan2(dx);
}
(2, GripApply::Absolute(p)) => {
let dx = p.x - arc.center.x;
let dy = p.y - arc.center.y;
arc.end_angle = dy.atan2(dx);
}
(3, GripApply::Translate(d)) => {
let sa = arc.start_angle;
let ea = arc.end_angle;
let span = angle_span(sa as f32, ea as f32) as f64;
let mid_a = sa + span * 0.5;
let current_mid_x = arc.center.x + arc.radius * mid_a.cos();
let current_mid_y = arc.center.y + arc.radius * mid_a.sin();
let new_mid_x = current_mid_x + d.x;
let new_mid_y = current_mid_y + d.y;
let dx = new_mid_x - arc.center.x;
let dy = new_mid_y - arc.center.y;
let new_r = (dx * dx + dy * dy).sqrt();
if new_r > 1e-6 {
arc.radius = new_r;
}
(1..=3, GripApply::Absolute(p)) => {
let original = arc.clone();
let _ = refit_grips(arc, &original, &[(grip_id, p)]);
}
_ => {}
}
@ -316,6 +383,29 @@ impl crate::entities::traits::Grippable for Arc {
}
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
use crate::scene::model::object::GripMenuAction as A;
if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 {
return None;
}
let cursor_angle = (point.y - self.center.y).atan2(point.x - self.center.x);
let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
let desired_sweep = match grip_id {
1 => (self.end_angle - cursor_angle).rem_euclid(TAU),
2 => (cursor_angle - self.start_angle).rem_euclid(TAU),
_ => return None,
};
if desired_sweep <= 1.0e-9 {
return None;
}
Some((desired_sweep - current_sweep) * self.radius)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,

View file

@ -210,6 +210,34 @@ impl crate::entities::traits::Grippable for Line {
}
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
use crate::scene::model::object::GripMenuAction as A;
if !matches!(action, A::Lengthen) {
return None;
}
let direction = glam::DVec3::new(
self.end.x - self.start.x,
self.end.y - self.start.y,
self.end.z - self.start.z,
);
let length = direction.length();
if length < 1.0e-12 {
return None;
}
let unit = direction / length;
let value = match grip_id {
0 => (glam::DVec3::new(self.start.x, self.start.y, self.start.z) - point).dot(unit),
1 => (point - glam::DVec3::new(self.end.x, self.end.y, self.end.z)).dot(unit),
_ => return None,
};
(length + value > 1.0e-9).then_some(value)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,

View file

@ -57,6 +57,17 @@ pub trait Grippable {
) -> Option<&'static str> {
None
}
/// Convert an interactive cursor point into the numeric value consumed by
/// `apply_grip_menu_value`. Actions without a point-driven form return
/// `None`.
fn grip_menu_point_value(
&self,
_grip_id: usize,
_action: GripMenuAction,
_point: glam::DVec3,
) -> Option<f64> {
None
}
}
pub trait PropertyEditable {
@ -163,6 +174,12 @@ pub trait EntityTypeOps {
grip_id: usize,
action: GripMenuAction,
) -> Option<&'static str>;
fn grip_menu_point_value(
&self,
grip_id: usize,
action: GripMenuAction,
point: glam::DVec3,
) -> Option<f64>;
fn apply_grip_menu_value(
&mut self,
grip_id: usize,
@ -405,6 +422,28 @@ impl EntityTypeOps for EntityType {
)
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
dispatch!(self,
|e| Grippable::grip_menu_point_value(e, grip_id, action, point),
[
Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D,
Polyline3D, Ray, XLine, RasterImage, Wipeout,
AttributeDefinition, AttributeEntity, MLine, Tolerance,
Solid, Solid3D, Region, Body, Surface, Face3D, PolygonMesh,
PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText,
Viewport, Insert, Leader, MultiLeader, Dimension, Hatch,
Underlay, Shape, Ole2Frame, SectionSymbol, ViewBorder,
Extended,
],
_ => None,
)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,