Sketch drag feel like Fusion (sketch.drag): the grabbed point goes exactly to the cursor, a line (spline, conic) slides with it, a circle or arc grabbed on its edge changes radius about its centre (an ellipse its minor radius), the centre moves the circle; the grabbed geometry is held while the rest moves as little as it can; fixed and fully constrained geometry stays. Dragging a curve of the sketch being edited now drags it (it used to start a box selection). Tests for per-entity fully-constrained feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
bbd0a19260
commit
f6b42f8750
3 files changed
+214
-22
No files matched your search
@@ -154,6 +154,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
|
||||
),
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
|
||||
.enabled(in_sketch)
|
||||
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
|
||||
@@ -1505,18 +1508,109 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let q = point_ref(sk, p.get("point"), cmd, "point")?;
|
||||
let locked = sk.point_locked(q);
|
||||
if let Some(pt) = sk.points.get_mut(q)
|
||||
&& !locked
|
||||
{
|
||||
pt.pos = to;
|
||||
}
|
||||
// Move whole circles when their centre moves (keeps the radius).
|
||||
drag_entity(sk, Ent::Point(q), to, to);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = p.get("from").and_then(vec2).unwrap_or(to);
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
drag_entity(sk, e, from, to);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
}
|
||||
|
||||
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
|
||||
/// fully constrained geometry stays (the solve that follows pulls it back).
|
||||
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
let d = to - from;
|
||||
let mut hold = Hold::default();
|
||||
match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return;
|
||||
}
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = to;
|
||||
}
|
||||
hold.points.push(q);
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
|
||||
let locked = sk.locked_points();
|
||||
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
|
||||
if !locked.get(q).copied().unwrap_or(true)
|
||||
&& let Some(pt) = sk.points.get_mut(q)
|
||||
{
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, .. } => {
|
||||
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
|
||||
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
&& r > 1e-9
|
||||
{
|
||||
*slot = r;
|
||||
}
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let Some(o) = sk.point(cc) else { return };
|
||||
let r = o.dist(to);
|
||||
if r > 1e-9 {
|
||||
for q in [a, b] {
|
||||
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
|
||||
set(sk, q, o + u * r);
|
||||
}
|
||||
}
|
||||
}
|
||||
hold.points.extend([cc, a, b]);
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, .. } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
if minor > 1e-9
|
||||
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
{
|
||||
*slot = minor;
|
||||
}
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
}
|
||||
hold.points.extend(k.point_ids());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut trial = sk.clone();
|
||||
if solve_holding(&mut trial, &hold).ok() {
|
||||
*sk = trial;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
|
||||
fn options_json(v: &solvecraft_sketch::SketchView) -> Value {
|
||||
json!({
|
||||
|
||||
@@ -433,3 +433,96 @@ fn palette_options_are_kept_with_each_sketch() {
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(!sketch(&s).view.hide_points);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dragging
|
||||
|
||||
fn drag(s: &mut Session, e: &str, from: [f64; 2], to: [f64; 2]) {
|
||||
run(s, "sketch.drag", json!({"entity": e, "from": from, "to": to}));
|
||||
}
|
||||
|
||||
fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
|
||||
solvecraft_geom::Vec2::new(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]}));
|
||||
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
|
||||
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
|
||||
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
|
||||
assert!(pt(&s, "l1.end").dist(v(50.0, 0.0)) < 1e-9);
|
||||
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_rectangle_side_moves_that_side_only() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]}));
|
||||
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
|
||||
let left = snap(&s, "l4");
|
||||
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
|
||||
assert!(same(&snap(&s, "l4"), &left), "left side moved");
|
||||
let (a, b) = (pt(&s, "l2.start"), pt(&s, "l2.end"));
|
||||
assert!((a.x - 50.0).abs() < 1e-6 && (b.x - 50.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_circles_and_arcs() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
|
||||
// The edge changes the radius about a fixed centre; the centre moves the circle.
|
||||
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
|
||||
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
|
||||
assert!(pt(&s, "c1.center").dist(v(0.0, 0.0)) < 1e-9);
|
||||
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
|
||||
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
|
||||
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
|
||||
run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
|
||||
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
|
||||
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
|
||||
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
|
||||
assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_geometry_resists_drags() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
|
||||
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]}));
|
||||
let l0 = snap(&s, "l1");
|
||||
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
|
||||
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
|
||||
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_constrained_feedback_is_per_entity() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]}));
|
||||
run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 3);
|
||||
for c in si["curves"].as_array().expect("curves") {
|
||||
assert_eq!(c["fully_constrained"], c["type"] == "line", "{c}");
|
||||
}
|
||||
// Colours follow: fully constrained lines in the fixed colour, the free circle in blue.
|
||||
let sk = sketch(&s);
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(s.active_sketch.expect("sketch")).expect("solved");
|
||||
let lines = crate::view::sketch_lines(&sk, &ss.plane, true, &ss.report.curve_determined);
|
||||
assert!(lines.iter().filter(|l| l.1 == crate::view::colors::SKETCH_FIXED).count() >= 4);
|
||||
assert!(lines.iter().any(|l| l.1 == crate::view::colors::SKETCH));
|
||||
}
|
||||
@@ -48,9 +48,9 @@ pub struct ViewportState {
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
/// A sketch point being dragged (its id, and the undo depth when the drag began: the whole
|
||||
/// drag is one undo step).
|
||||
pub point_drag: Option<(String, usize)>,
|
||||
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
|
||||
/// whole drag is one undo step, and for a curve where it was last under the cursor).
|
||||
pub point_drag: Option<(String, usize, Option<Vec2>)>,
|
||||
}
|
||||
|
||||
/// Something under the cursor.
|
||||
@@ -1176,26 +1176,31 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
crate::dim_view::drag_to(app, q);
|
||||
}
|
||||
// A point or a curve of the sketch being edited follows a drag that starts on it.
|
||||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||||
&& !crate::dim_view::dragging()
|
||||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(Hit::SketchPoint { id, .. }) = pick(app, &proj, a).into_iter().next()
|
||||
{
|
||||
app.viewport.point_drag = Some((id, app.session.undo.len()));
|
||||
}
|
||||
if let Some((id, depth)) = app.viewport.point_drag.clone()
|
||||
&& resp.dragged_by(egui::PointerButton::Primary)
|
||||
&& let Some(p) = hover
|
||||
&& let Some(ss) = app.session.active_sketch.and_then(|sid| app.session.model.state().sketch(sid).cloned())
|
||||
&& let Some(w) = {
|
||||
let (o, d) = proj.ray(p);
|
||||
ss.plane.intersect_ray(o, d)
|
||||
let active = app.session.active_sketch;
|
||||
match pick(app, &proj, a).into_iter().next() {
|
||||
Some(Hit::SketchPoint { id, .. }) => app.viewport.point_drag = Some((id, app.session.undo.len(), None)),
|
||||
Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => {
|
||||
app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((id, depth, grab)) = app.viewport.point_drag.clone()
|
||||
&& resp.dragged_by(egui::PointerButton::Primary)
|
||||
&& let Some(q) = hover.and_then(|p| on_plane(app, p))
|
||||
{
|
||||
let q = ss.plane.to_local(w);
|
||||
if app.session.execute("sketch.move_point", &json!({"point": id, "to": [q.x, q.y]})).is_ok() {
|
||||
let from = grab.unwrap_or(q);
|
||||
if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() {
|
||||
app.session.undo.truncate(depth + 1);
|
||||
}
|
||||
if grab.is_some() {
|
||||
app.viewport.point_drag = Some((id, depth, Some(q)));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.viewport.point_drag = None;
|
||||
|
||||
Reference in new issue
Block a user