From 608aa225a759b4ae324eaff9024744f5f1f66acb Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:22:48 +0200 Subject: [PATCH] Grips: hot-grip options (Base point/Copy/Undo/Reference/eXit) and multiple hot grips (part of #425) (#435) Squash of PR #435 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/gripcmds.rs | 118 +++++++- crates/engine/src/cmd/griphot.rs | 410 ++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/guard.rs | 5 + crates/engine/tests/grip_options.rs | 194 +++++++++++++ crates/ui-egui/src/canvas.rs | 135 ++++----- crates/ui-egui/src/cmdline.rs | 148 ++++------ crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/quick.rs | 1 - docs/control-protocol.md | 2 +- 12 files changed, 844 insertions(+), 185 deletions(-) create mode 100644 crates/engine/src/cmd/griphot.rs create mode 100644 crates/engine/tests/grip_options.rs diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs index 7065a8c6..50516326 100644 --- a/crates/engine/src/cmd/gripcmds.rs +++ b/crates/engine/src/cmd/gripcmds.rs @@ -1,6 +1,6 @@ //! `grip.*` commands: the undoable, scriptable form of grip editing (see `crate::grips`). -use cadcraft_doc::Handle; +use cadcraft_doc::{Entity, EntityKind, Handle}; use cadcraft_geom::{Mat3, Vec2}; use serde_json::{Value, json}; @@ -14,11 +14,16 @@ pub fn specs() -> Vec { CommandSpec::new("grip.move", "Grip Stretch", run_grip_move).params( "{handle?, handles?, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)", ), - CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle?, handles?, index, to}"), + CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params( + "{handle?, handles?, index, to} | {grips: [[handle, index], ...] (several hot grips), delta | to (+ base?), handles?, copy?}", + ), CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"), CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"), CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror) .params("{handles?, base | baseHandle+index, to (second mirror point), copy?}"), + CommandSpec::new("grip", "Grip Edit", run_grip) + .params("interactive: select grips, then the grip modes with [Base point/Copy/Undo/Reference/eXit]; JSON: use grip.stretch/move/rotate/scale/mirror") + .interactive(super::griphot::factory), ] } @@ -80,7 +85,114 @@ fn apply(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result } fn run_grip_stretch(s: &mut Session, p: &Value) -> Result { - stretch(s, "grip.stretch", p) + if p.get("grips").is_none() { + return stretch(s, "grip.stretch", p); + } + let cmd = "grip.stretch"; + let list = p.get("grips").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`grips` must be an array of [handle, index] pairs"))?; + let mut hot = Vec::new(); + for g in list.iter().take(MAX_GRIPS) { + let pair = || -> Option<(Handle, usize)> { + let h = curves::handle_value(g.get(0)?)?; + Some((h, usize::try_from(g.get(1)?.as_u64()?).ok()?)) + }; + hot.push(pair().ok_or_else(|| bad(cmd, format!("`grips`: {g} is not a [handle, index] pair")))?); + } + let &(h0, i0) = hot.first().ok_or_else(|| bad(cmd, "`grips` is empty"))?; + let delta = match point_param(p, "delta") { + Some(d) => d, + None => { + let base = match point_param(p, "base") { + Some(b) => b, + None => curves::entity(s, h0)?.kind.grips().get(i0).copied().ok_or_else(|| bad(cmd, "no such grip"))?, + }; + point_req(cmd, p, "to")? - base + } + }; + let others = if p.get("handles").is_some() { targets(s, p)? } else { s.selection() }; + let hs = stretch_grips(s, &hot, &others, delta, bool_or(p, "copy", false))?; + if hs.is_empty() { + return Err(bad(cmd, "no grip could be stretched (locked, missing, or the result would be degenerate)")); + } + Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_grip(_s: &mut Session, _p: &Value) -> Result { + Err(bad("grip", "GRIP is interactive; use grip.stretch, grip.move, grip.rotate, grip.scale or grip.mirror")) +} + +/// At most this many grips or objects take part in one multi-grip stretch. +const MAX_GRIPS: usize = 1000; + +/// The objects as they'd be after moving the grips `hot` by `delta`, with the grips of `others` +/// that sit on a hot grip's point (connected objects stay connected). Locked objects and edits +/// that would be degenerate are left out; several hot grips of one object move together. +pub(super) fn stretched(s: &Session, hot: &[(Handle, usize)], others: &[Handle], delta: Vec2) -> Vec<(Handle, EntityKind)> { + let Ok(d) = s.doc() else { return Vec::new() }; + // Every grip to move, per object, with its target point. + let mut moves: Vec<(Handle, Vec<(usize, Vec2)>)> = Vec::new(); + let mut add = |h: Handle, i: usize, to: Vec2| match moves.iter_mut().find(|(x, _)| *x == h) { + Some((_, v)) => { + if !v.iter().any(|(j, _)| *j == i) { + v.push((i, to)); + } + } + None => moves.push((h, vec![(i, to)])), + }; + for &(h, i) in hot.iter().take(MAX_GRIPS) { + let Some(from) = d.entity(h).and_then(|e| e.kind.grips().get(i).copied()) else { continue }; + add(h, i, from + delta); + let tol = 1e-9 * (1.0 + from.len()); + for &o in others.iter().take(MAX_GRIPS) { + if o == h { + continue; + } + if let Some(j) = d.entity(o).and_then(|e| e.kind.grips().iter().position(|g| g.near(from, tol))) { + add(o, j, from + delta); + } + } + } + let mut out = Vec::new(); + for (h, mv) in moves { + if curves::is_locked(s, h) { + continue; + } + let Some(e) = d.entity(h) else { continue }; + let mut k = e.kind.clone(); + let mut changed = false; + for (i, to) in mv { + if let Some(nk) = stretch_grip(&k, i, to) { + k = nk; + changed = true; + } + } + if changed { + out.push((h, k)); + } + } + out +} + +/// Stretch the grips `hot` by `delta` (see [`stretched`]). With `copy` the stretched objects are +/// added as copies and the originals stay. Returns the edited (or new) objects. +pub(super) fn stretch_grips(s: &mut Session, hot: &[(Handle, usize)], others: &[Handle], delta: Vec2, copy: bool) -> Result> { + let edits = stretched(s, hot, others, delta); + let d = s.doc_mut()?; + let mut out = Vec::new(); + for (h, k) in edits { + if copy { + let (Some(sp), Some(e)) = (d.space_of(h), d.entity(h).map(|e| (**e).clone())) else { continue }; + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&sp) { + st.push(Entity { handle: nh, kind: k, ..e }); + out.push(nh); + } + } else { + d.modify_entity(h, |e| e.kind = k)?; + out.push(h); + } + } + Ok(out) } fn run_grip_move(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/griphot.rs b/crates/engine/src/cmd/griphot.rs new file mode 100644 index 00000000..e1da6ce7 --- /dev/null +++ b/crates/engine/src/cmd/griphot.rs @@ -0,0 +1,410 @@ +//! Hot grips: what happens once a grip is clicked. AutoCAD's grip modes STRETCH, MOVE, ROTATE, +//! SCALE and MIRROR (Space/Enter cycles them) with their options: Base point (pick another base), +//! Copy (keep editing copies until Enter or eXit), Undo (take back the last copy), Reference +//! (ROTATE and SCALE) and eXit. Several grips can be hot at once; STRETCH moves them together. +//! +//! The UI starts it with [`Session::start_grip_edit`]; typed, `GRIP` asks for the grips first. +//! The whole grip edit, copies included, is one undo step. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::{Mat3, Vec2}; + +use super::gripcmds::stretch_grips; +use super::helpers::line; +use super::machines::number; +use super::modify::transform_entities; +use crate::grips::{GripMode, mode_matrix}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Running, Session, Step}; + +/// At most this many grips are hot at once (input-derived: a cap on the work per edit). +const MAX_HOT: usize = 1000; + +/// What the prompt asks for besides the mode's own point. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ask { + /// The mode prompt ("** STRETCH ** Specify stretch point…"). + Mode, + /// Base point option. + Base, + /// Reference: the reference angle/length, typed or as a first point. + RefFirst, + /// Reference: the second point of the reference angle/length. + RefSecond(Vec2), + /// Reference: the new angle/length (the reference value is known). + RefNew(f64), +} + +pub(crate) struct GripEdit { + /// The hot grips (object, grip index); the first is the base grip. + hot: Vec<(Handle, usize)>, + /// Typed `GRIP`: still collecting grips ("Select grips"). + picking: bool, + /// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection. + targets: Vec, + base: Vec2, + mode: GripMode, + copy: bool, + ask: Ask, + /// The drawing before each edit made in Copy mode (Undo). + undo: Vec>, +} + +/// The `GRIP` command's machine: select grips, then edit them. +pub(crate) fn factory(s: &Session) -> Result> { + let mut g = GripEdit::new(s, Vec::new())?; + g.picking = true; + Ok(Box::new(g)) +} + +fn other(msg: &str) -> EngineError { + EngineError::Other(msg.into()) +} + +/// A finite length greater than zero. +fn positive(v: Option) -> Result { + v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number.")) +} + +impl GripEdit { + fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result { + let mut g = GripEdit { + hot: Vec::new(), + picking: false, + targets: Vec::new(), + base: Vec2::ZERO, + mode: GripMode::Stretch, + copy: false, + ask: Ask::Mode, + undo: Vec::new(), + }; + for h in hot { + g.add_hot(s, h)?; + } + Ok(g) + } + + /// Make grip `index` of `h` hot (the first one becomes the base point). + fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> { + if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT { + return Ok(()); + } + let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?; + if self.hot.is_empty() { + self.base = p; + self.targets.push(h); + self.targets.extend(s.selection().into_iter().filter(|x| *x != h)); + } else if !self.targets.contains(&h) { + self.targets.push(h); + } + self.hot.push((h, i)); + Ok(()) + } + + /// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there. + fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> { + let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len())); + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out = Vec::new(); + for h in s.selection().into_iter().take(MAX_HOT) { + if super::curves::is_locked(s, h) { + continue; + } + let Some(e) = d.entity(h) else { continue }; + let gs = e.kind.grips(); + // The nearest grip of this object within the tolerance. + let best = gs + .iter() + .enumerate() + .filter(|(_, g)| (g.x - p.x).abs() <= tol && (g.y - p.y).abs() <= tol) + .min_by(|a, b| a.1.dist(p).partial_cmp(&b.1.dist(p)).unwrap_or(std::cmp::Ordering::Equal)); + if let Some((i, _)) = best { + out.push((h, i)); + } + } + out + } + + fn label(&self) -> &'static str { + match self.mode { + GripMode::Stretch => "** STRETCH **", + GripMode::Move => "** MOVE **", + GripMode::Rotate => "** ROTATE **", + GripMode::Scale => "** SCALE **", + GripMode::Mirror => "** MIRROR **", + } + } + + fn next_mode(&mut self) { + self.mode = match self.mode { + GripMode::Stretch => GripMode::Move, + GripMode::Move => GripMode::Rotate, + GripMode::Rotate => GripMode::Scale, + GripMode::Scale => GripMode::Mirror, + GripMode::Mirror => GripMode::Stretch, + }; + } + + /// The transform of the current mode for the point `to` (not STRETCH). + fn matrix(&self, to: Vec2) -> Option { + match self.ask { + Ask::RefNew(r) if self.mode == GripMode::Rotate => Some(Mat3::rotate_about(self.base, self.base.angle_to(to) - r)), + Ask::RefNew(r) if self.mode == GripMode::Scale => { + let f = self.base.dist(to) / r; + (f.is_finite() && f > 1e-12).then(|| Mat3::scale_about(self.base, f)) + } + _ => mode_matrix(self.mode, self.base, to), + } + } + + /// Edit with the dragged point `to` (STRETCH) or the transform `m` (the other modes). + fn edit(&mut self, s: &mut Session, to: Vec2, m: Option) -> Result { + let before = s.state()?.doc.clone(); + let changed = match (self.mode, m) { + (GripMode::Stretch, _) => !stretch_grips(s, &self.hot, &self.targets, to - self.base, self.copy)?.is_empty(), + (_, Some(m)) => !transform_entities(s, &self.targets, &m, self.copy)?.is_empty(), + (_, None) => return Err(other("Invalid point.")), + }; + if !changed { + return Err(other("The objects can't be edited that way (locked, or the result would be degenerate).")); + } + self.ask = Ask::Mode; + if self.copy { + self.undo.push(before); + Ok(Step::Continue) + } else { + Ok(Step::Done) + } + } + + fn mode_input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Keyword(k) => match k.as_str() { + "Base point" => { + self.ask = Ask::Base; + Ok(Step::Continue) + } + "Copy" => { + self.copy = true; + Ok(Step::Continue) + } + "Undo" => match self.undo.pop() { + Some(doc) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + Ok(Step::Continue) + } + None => Err(other("Nothing to undo.")), + }, + "Reference" if matches!(self.mode, GripMode::Rotate | GripMode::Scale) => { + self.ask = Ask::RefFirst; + Ok(Step::Continue) + } + "eXit" => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + // Enter cycles the modes; after copies it ends the edit. + Input::Enter => { + if !self.undo.is_empty() { + return Ok(Step::Done); + } + self.next_mode(); + s.echo(self.label()); + Ok(Step::Continue) + } + Input::Point(p) => { + let m = self.matrix(p); + self.edit(s, p, m) + } + Input::Deferred(d) => { + let m = self.matrix(d.at); + self.edit(s, d.at, m) + } + Input::Text(t) => match self.mode { + GripMode::Rotate => { + let a = s.angle_settings().rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point."))?; + self.edit(s, self.base, Some(Mat3::rotate_about(self.base, a))) + } + GripMode::Scale => { + let f = positive(number(&t))?; + self.edit(s, self.base, Some(Mat3::scale_about(self.base, f))) + } + _ => Err(other("Requires a point or an option keyword.")), + }, + Input::Pick(_) | Input::Cancel => Ok(Step::Continue), + } + } + + /// Reference (ROTATE: angles, SCALE: lengths): the reference value typed or from two points, + /// then the new value typed or measured from the base point to a picked point. + fn reference_input(&mut self, s: &mut Session, i: Input) -> Result { + let rotate = self.mode == GripMode::Rotate; + let au = s.angle_settings(); + let value = |t: &str| -> Result { + if rotate { au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point.")) } else { positive(number(t)) } + }; + let between = |a: Vec2, b: Vec2| -> Result { + if a.near(b, 1e-12) { + return Err(other("The two points must differ.")); + } + Ok(if rotate { a.angle_to(b) } else { a.dist(b) }) + }; + match (self.ask, i) { + (Ask::RefFirst, Input::Point(p)) => self.ask = Ask::RefSecond(p), + (Ask::RefFirst, Input::Text(t)) => self.ask = Ask::RefNew(value(&t)?), + (Ask::RefFirst, Input::Enter) => self.ask = Ask::RefNew(if rotate { 0.0 } else { 1.0 }), + (Ask::RefSecond(a), Input::Point(p)) => self.ask = Ask::RefNew(between(a, p)?), + (Ask::RefNew(_), Input::Point(p)) => { + let m = self.matrix(p).ok_or_else(|| other("Requires a positive length."))?; + return self.edit(s, p, Some(m)); + } + (Ask::RefNew(r), Input::Text(t)) => { + let v = value(&t)?; + let m = if rotate { Mat3::rotate_about(self.base, v - r) } else { Mat3::scale_about(self.base, positive(Some(v / r))?) }; + return self.edit(s, self.base, Some(m)); + } + (_, Input::Enter) => self.ask = Ask::Mode, + _ => {} + } + Ok(Step::Continue) + } +} + +impl Interactive for GripEdit { + fn name(&self) -> &'static str { + "GRIP" + } + + fn begin(&mut self, _s: &mut Session) -> Result { + Ok(Step::Continue) + } + + fn prompt(&self, _s: &Session) -> Prompt { + if self.picking { + let msg = if self.hot.is_empty() { "Select grip" } else { "Select grip or " }; + return Prompt::new(msg, Accept::POINT); + } + let base = self.base; + match self.ask { + Ask::Base => Prompt::new("Specify base point", Accept::POINT), + Ask::RefFirst if self.mode == GripMode::Rotate => Prompt::new("Specify reference angle", Accept::POINT_OR_NUMBER).default("0"), + Ask::RefFirst => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), + Ask::RefSecond(a) => Prompt::new("Specify second point", Accept::POINT).base(a), + Ask::RefNew(_) if self.mode == GripMode::Rotate => Prompt::new("Specify new angle", Accept::POINT_OR_NUMBER).base(base), + Ask::RefNew(_) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base(base), + Ask::Mode => { + let (msg, accept, kw): (&str, Accept, &[&str]) = match self.mode { + GripMode::Stretch => ("** STRETCH ** Specify stretch point", Accept::POINT, &["Base point", "Copy", "Undo", "eXit"]), + GripMode::Move => ("** MOVE ** Specify move point", Accept::POINT, &["Base point", "Copy", "Undo", "eXit"]), + GripMode::Rotate => { + ("** ROTATE ** Specify rotation angle", Accept::POINT_OR_NUMBER, &["Base point", "Copy", "Undo", "Reference", "eXit"]) + } + GripMode::Scale => { + ("** SCALE ** Specify scale factor", Accept::POINT_OR_NUMBER, &["Base point", "Copy", "Undo", "Reference", "eXit"]) + } + GripMode::Mirror => ("** MIRROR ** Specify second point", Accept::POINT, &["Base point", "Copy", "Undo", "eXit"]), + }; + Prompt::new(msg, accept).kw(kw).base(base) + } + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.picking { + return match i { + Input::Point(p) => { + let found = Self::grips_at(s, p); + if found.is_empty() { + return Err(other("No grip of a selected object there.")); + } + for g in found { + self.add_hot(s, g)?; + } + Ok(Step::Continue) + } + Input::Enter if !self.hot.is_empty() => { + self.picking = false; + s.echo(self.label()); + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + _ => Err(other("Requires a point on a grip.")), + }; + } + match self.ask { + Ask::Mode => self.mode_input(s, i), + Ask::Base => match i { + Input::Point(p) => { + self.base = p; + self.ask = Ask::Mode; + Ok(Step::Continue) + } + Input::Enter => { + self.ask = Ask::Mode; + Ok(Step::Continue) + } + _ => Err(other("Requires a point.")), + }, + _ => self.reference_input(s, i), + } + } + + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.picking { + return Vec::new(); + } + let base = match self.ask { + Ask::Mode | Ask::RefNew(_) => self.base, + Ask::RefSecond(a) => return vec![line(a, c)], + Ask::Base | Ask::RefFirst => return Vec::new(), + }; + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = if self.mode == GripMode::Stretch { + super::gripcmds::stretched(s, &self.hot, &self.targets, c - base).into_iter().take(500).map(|(_, k)| k).collect() + } else { + match self.matrix(c) { + Some(m) => self + .targets + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + k.transform(&m); + k + }) + .collect(), + None => Vec::new(), + } + }; + out.push(line(base, c)); + out + } +} + +impl Session { + /// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects + /// several first). `hot` lists (object, grip index); the first is the base grip. The grip + /// modes and their options then run at the command line like any prompt. + pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> { + if self.running.is_some() { + self.cancel(); + } + if hot.is_empty() { + return Err(other("no grip")); + } + let g = GripEdit::new(self, hot.to_vec())?; + let label = g.label(); + let machine = crate::guard::Guarded::wrap(Box::new(g)); + let st = self.state()?; + let (before, selection_before) = (st.doc.clone(), st.selection.clone()); + self.running = Some(Running { id: "grip".into(), machine, before, selection_before }); + self.pending_window = None; + self.point_mods.clear(); + self.echo(label); + Ok(()) + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4880be7d..7f5c6be7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -13,6 +13,7 @@ mod edit; mod exchange; pub mod file; mod gripcmds; +mod griphot; pub(crate) mod groups; mod hatch; mod inquiry; diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 01b647be..3e1f9e70 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -33,6 +33,11 @@ impl Guarded { Ok(Box::new(Guarded { inner, broken: Cell::new(None) })) } + /// Wrap a machine created by other means (the hot-grip edit). + pub(crate) fn wrap(inner: Box) -> Box { + Box::new(Guarded { inner, broken: Cell::new(None) }) + } + fn internal(&self, p: &(dyn Any + Send)) -> EngineError { EngineError::Internal(self.inner.name().to_ascii_lowercase(), message(p)) } diff --git a/crates/engine/tests/grip_options.rs b/crates/engine/tests/grip_options.rs new file mode 100644 index 00000000..51a2e276 --- /dev/null +++ b/crates/engine/tests/grip_options.rs @@ -0,0 +1,194 @@ +//! Hot-grip options, typed at the command line as AutoCAD's grip modes take them: Base point, +//! Copy, Undo, Reference and eXit; Enter cycles the modes; several grips can be hot at once. +//! A whole grip edit, copies included, is one undo step. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() +} + +fn lines(s: &Session) -> Vec<(Vec2, Vec2)> { + let d = s.doc().unwrap(); + d.model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }) + .collect() +} + +fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) { + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + k => panic!("not a line: {k:?}"), + } +} + +fn near(p: Vec2, x: f64, y: f64) -> bool { + p.near(Vec2::new(x, y), 1e-9) +} + +fn grip_index(s: &Session, h: Handle, x: f64, y: f64) -> usize { + s.grips_of(h).unwrap().iter().position(|g| near(*g, x, y)).unwrap() +} + +fn typed(s: &mut Session, inputs: &[&str]) { + for t in inputs { + s.cmdline(t).unwrap(); + } +} + +#[test] +fn base_point_copy_and_undo_make_one_undo_step() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.set_selection(vec![a]); + let undo = s.state().unwrap().undo.len(); + s.start_grip_edit(&[(a, grip_index(&s, a, 10.0, 0.0))]).unwrap(); + assert!(s.prompt_text().contains("** STRETCH ** Specify stretch point or [Base point/Copy/Undo/eXit]"), "{}", s.prompt_text()); + + // Base point 0,0, then Copy: each point stretches a new copy by (point - base). + typed(&mut s, &["B", "0,0", "C", "5,5", "0,5", "0,9"]); + assert_eq!(lines(&s).len(), 4, "the original and three copies"); + // Undo takes back the last copy only. + typed(&mut s, &["U"]); + assert_eq!(lines(&s).len(), 3); + assert!(s.running.is_some(), "Copy mode carries on"); + // Enter after copies ends the edit. + typed(&mut s, &[""]); + assert!(s.running.is_none()); + let ls = lines(&s); + assert!(near(ends(&s, a).1, 10.0, 0.0), "the original stays"); + assert!(ls.iter().any(|(p, q)| near(*p, 0.0, 0.0) && near(*q, 15.0, 5.0)), "{ls:?}"); + assert!(ls.iter().any(|(p, q)| near(*p, 0.0, 0.0) && near(*q, 10.0, 5.0)), "{ls:?}"); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step for the whole edit"); + s.undo().unwrap(); + assert_eq!(lines(&s).len(), 1); +} + +#[test] +fn enter_cycles_the_modes_and_typed_values_apply() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.set_selection(vec![a]); + s.start_grip_edit(&[(a, 0)]).unwrap(); + typed(&mut s, &[""]); + assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text()); + typed(&mut s, &[""]); + assert!(s.prompt_text().contains("** ROTATE ** Specify rotation angle or [Base point/Copy/Undo/Reference/eXit]"), "{}", s.prompt_text()); + // A typed angle rotates about the grip; without Copy the edit ends. + typed(&mut s, &["90"]); + assert!(s.running.is_none()); + let (p, q) = ends(&s, a); + assert!(near(p, 0.0, 0.0) && near(q, 0.0, 10.0), "{p:?} {q:?}"); + + // SCALE takes a typed factor; MIRROR a second point; after MIRROR comes STRETCH again. + s.start_grip_edit(&[(a, 0)]).unwrap(); + typed(&mut s, &["", "", "", "2"]); + assert!(near(ends(&s, a).1, 0.0, 20.0), "{:?}", ends(&s, a)); + s.start_grip_edit(&[(a, 0)]).unwrap(); + typed(&mut s, &["", "", "", ""]); + assert!(s.prompt_text().contains("** MIRROR ** Specify second point"), "{}", s.prompt_text()); + typed(&mut s, &[""]); + assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text()); + // eXit leaves the objects as they are. + typed(&mut s, &["X"]); + assert!(s.running.is_none()); + assert!(near(ends(&s, a).1, 0.0, 20.0)); +} + +#[test] +fn reference_rotate_and_scale() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.set_selection(vec![a]); + // ROTATE Reference: from 45° to 90° turns by 45°. + s.start_grip_edit(&[(a, 0)]).unwrap(); + typed(&mut s, &["", "", "R", "45", "90"]); + let q = ends(&s, a).1; + let r = std::f64::consts::FRAC_1_SQRT_2 * 10.0; + assert!(near(q, r, r), "{q:?}"); + + // SCALE Reference with two points: reference length 5 (two points), new length 10. + let b = line(&mut s, [0.0, 0.0], [4.0, 0.0]); + s.set_selection(vec![b]); + s.start_grip_edit(&[(b, 0)]).unwrap(); + typed(&mut s, &["", "", "", "R", "0,0", "5,0", "10"]); + assert!(near(ends(&s, b).1, 8.0, 0.0), "{:?}", ends(&s, b)); + assert!(s.running.is_none()); +} + +#[test] +fn move_with_base_point_and_copy_on_the_selection() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]); + s.set_selection(vec![a, b]); + s.start_grip_edit(&[(a, 0)]).unwrap(); + // MOVE, Copy: both selected lines are copied by each displacement. + typed(&mut s, &["", "C", "0,20", "0,40", "X"]); + assert!(s.running.is_none()); + assert_eq!(lines(&s).len(), 6); + assert!(near(ends(&s, a).0, 0.0, 0.0), "the originals stay"); +} + +#[test] +fn several_hot_grips_stretch_together() { + let mut s = Session::new(); + s.execute("pline", &json!({ "vertices": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true })).unwrap(); + let h = s.doc().unwrap().model.iter().last().unwrap().handle; + s.set_selection(vec![h]); + let (i, j) = (grip_index(&s, h, 10.0, 0.0), grip_index(&s, h, 10.0, 10.0)); + s.start_grip_edit(&[(h, i), (h, j)]).unwrap(); + typed(&mut s, &["15,0"]); + assert!(s.running.is_none()); + let g = s.grips_of(h).unwrap(); + assert!(g.iter().any(|p| near(*p, 15.0, 0.0)) && g.iter().any(|p| near(*p, 15.0, 10.0)), "{g:?}"); + + // Typed: GRIP, pick the hot grips, Enter, then the stretch point. + typed(&mut s, &["GRIP", "0,0", "0,10", "", "-5,0"]); + assert!(s.running.is_none()); + let g = s.grips_of(h).unwrap(); + assert!(g.iter().any(|p| near(*p, -5.0, 0.0)) && g.iter().any(|p| near(*p, -5.0, 10.0)), "{g:?}"); + + // JSON: grip.stretch with several grips. + let (i, j) = (grip_index(&s, h, 15.0, 0.0), grip_index(&s, h, 15.0, 10.0)); + s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i], [h.hex(), j]], "delta": [5, 0] })).unwrap(); + let g = s.grips_of(h).unwrap(); + assert!(g.iter().any(|p| near(*p, 20.0, 0.0)) && g.iter().any(|p| near(*p, 20.0, 10.0)), "{g:?}"); +} + +#[test] +fn bad_grips_are_errors_not_panics() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.set_selection(vec![a]); + assert!(s.start_grip_edit(&[]).is_err()); + assert!(s.start_grip_edit(&[(a, 99)]).is_err()); + assert!(s.start_grip_edit(&[(Handle(0xFFFF), 0)]).is_err()); + for p in [ + json!({ "grips": "x" }), + json!({ "grips": [] , "delta": [1, 0] }), + json!({ "grips": [[a.hex()]], "delta": [1, 0] }), + json!({ "grips": [[a.hex(), -1]], "delta": [1, 0] }), + json!({ "grips": [[a.hex(), 0]], "delta": [f64::MAX, 0] }), + json!({ "grips": [[a.hex(), 0]] }), + ] { + let _ = s.execute("grip.stretch", &p); + } + assert!(s.execute("grip", &json!({})).is_err()); + // Typed GRIP away from any grip re-prompts; nonsense at the mode prompt too. + typed(&mut s, &["GRIP", "50,50"]); + assert!(s.running.is_some()); + typed(&mut s, &["10,0", "", "foo", "1e400,0"]); + s.cancel(); + assert!(s.running.is_none()); + let (p, q) = ends(&s, a); + assert!(p.is_finite() && q.is_finite()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 837640b9..8cb23170 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,46 +35,6 @@ impl Xf { } } -#[derive(Clone, Copy, Debug)] -pub struct HotGrip { - pub handle: Handle, - pub index: usize, - pub base: Vec2, - pub mode: cadcraft_engine::grips::GripMode, -} - -impl HotGrip { - pub fn label(&self) -> &'static str { - use cadcraft_engine::grips::GripMode::*; - match self.mode { - Stretch => "** STRETCH ** Specify stretch point or [Base point/Copy/Undo/eXit]:", - Move => "** MOVE ** Specify move point or [Base point/Copy/Undo/eXit]:", - Rotate => "** ROTATE ** Specify rotation angle or [Base point/Copy/Undo/Reference/eXit]:", - Scale => "** SCALE ** Specify scale factor or [Base point/Copy/Undo/Reference/eXit]:", - Mirror => "** MIRROR ** Specify second point or [Base point/Copy/Undo/eXit]:", - } - } - pub fn next_mode(&mut self) { - use cadcraft_engine::grips::GripMode::*; - self.mode = match self.mode { - Stretch => Move, - Move => Rotate, - Rotate => Scale, - Scale => Mirror, - Mirror => Stretch, - }; - } -} - -/// Apply a hot grip at `to` (one undo step) and clear it. -pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) { - if let Some(g) = app.canvas.hot_grip.take() - && let Err(e) = app.session.grip_edit(g.handle, g.index, to, g.mode) - { - app.session.echo(e.to_string()); - } -} - #[derive(Default)] pub struct CanvasState { pub list: Option, @@ -104,8 +64,11 @@ pub struct CanvasState { /// True when the latest scroll came from a trackpad (or other precise-scrolling device): /// scrolling then pans and pinch zooms. A notched mouse wheel clears it so the wheel zooms. scroll_pans: bool, - /// A hot (clicked) grip being dragged: entity, grip index, grip position, mode. - pub hot_grip: Option, + /// Hot grips (object, grip index): the ones Shift+clicked while idle, then the ones the + /// running GRIP edit works on (the engine's grip modes take them from there). + pub hot_grips: Vec<(Handle, usize)>, + /// A GRIP edit was running last frame (its grips go cold when it ends). + grip_editing: bool, /// Zoom to extents once the canvas size is known (after opening a drawing). pub zoom_pending: bool, pub build_ms: f64, @@ -537,10 +500,9 @@ fn draw_tracking(p: &egui::Painter, xf: &Xf, acquired: &[snap::tracking::Acquire /// `Session::snap_cursor`: object snaps, acquiring tracking points, grid, tracking paths, ortho). fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) -> Vec2 { let prompt = app.session.current_prompt(); - let hot = app.canvas.hot_grip; - let wants_point = hot.is_some() || prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); - let base = hot.map(|g| g.base).or_else(|| prompt.as_ref().and_then(|p| p.base)); - let deferred = hot.is_none() && prompt.as_ref().is_some_and(|p| p.deferred); + let wants_point = prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); + let base = prompt.as_ref().and_then(|p| p.base); + let deferred = prompt.as_ref().is_some_and(|p| p.deferred); app.canvas.snap = None; app.canvas.track = None; if !wants_point || app.session.pending_window.is_some() { @@ -678,7 +640,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let pp = xf_paper.to_world(hp); let in_active = active_vp.as_ref().is_some_and(|v| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0); if !in_active { - app.canvas.hot_grip = None; + app.canvas.hot_grips.clear(); if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_vp.is_some() { let _ = app.run("pspace", serde_json::json!({})); } @@ -720,7 +682,10 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Some(p) = app.canvas.cursor { if app.session.running.is_some() { - app.canvas.hot_grip = None; + // Ctrl held while picking the point of a grip edit makes copies (the Copy option). + if mods.ctrl && app.session.running.as_ref().is_some_and(|r| r.id == "grip") { + let _ = app.session.input(Input::Keyword("Copy".into())); + } // Clicking places the point; a value typed into the Dynamic Input boxes is used up. if let Some(f) = dyn_frame && crate::dyninput::parse(&app.cmd.buffer, &f).is_some() @@ -732,11 +697,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if let Err(e) = app.session.input(input) { app.session.echo(e.to_string()); } - } else if app.canvas.hot_grip.is_some() { - apply_hot_grip(app, p); } else if let Some(g) = grip_at(app, &xf, hover_pos) { - app.canvas.hot_grip = Some(g); - app.session.echo(g.label()); + click_grip(app, g, mods.shift); } else if let Err(e) = app.session.idle_click(raw_world.unwrap_or(p), mods.shift) { app.session.echo(e.to_string()); } @@ -746,7 +708,6 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // and letting go closes it. Drags on grips and drags while drawing are left alone. if resp.drag_started_by(egui::PointerButton::Primary) && !middle_down - && app.canvas.hot_grip.is_none() && app.session.pending_window.is_none() && let Some(origin) = ui.input(|i| i.pointer.press_origin()) && rect.contains(origin) @@ -872,8 +833,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let p = painter.with_clip_rect(clip); crate::parametric::draw_overlay(app, ui, &p, &xf); } - // Grips on selected objects (when idle). - if app.session.running.is_none() + // Grips on selected objects (when idle or editing grips). + let grip_edit = app.session.running.as_ref().is_some_and(|r| r.id == "grip"); + if (!grip_edit && (app.canvas.grip_editing || app.session.running.is_some())) || sel.is_empty() { + app.canvas.hot_grips.clear(); + } + app.canvas.grip_editing = grip_edit; + if (app.session.running.is_none() || grip_edit) && let Ok(st) = app.session.state() { let gs = app.session.settings.gripsize as f32; @@ -883,7 +849,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let sp = xf.to_screen(g); if rect.contains(sp) { let r = Rect::from_center_size(sp, vec2(gs * 2.0, gs * 2.0)); - let hot = app.canvas.hot_grip.is_some_and(|hg| hg.handle == *h && hg.index == gi); + let hot = app.canvas.hot_grips.contains(&(*h, gi)); let warm = !hot && hover_pos.is_some_and(|hp| r.expand(2.0).contains(hp)); painter.rect_filled( r, @@ -902,30 +868,6 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } } - // Hot grip preview. - if let (Some(g), Some(c), Ok(st)) = (app.canvas.hot_grip, app.canvas.cursor, app.session.state()) - && let Some(e) = st.doc.entity(g.handle) - { - let k = match cadcraft_engine::grips::mode_matrix(g.mode, g.base, c) { - Some(m) => { - let mut k = e.kind.clone(); - k.transform(&m); - Some(k) - } - None if g.mode == cadcraft_engine::grips::GripMode::Stretch => cadcraft_engine::grips::stretch_grip(&e.kind, g.index, c), - None => None, - }; - if let Some(k) = k { - let ent = cadcraft_doc::Entity { handle: Handle(0), common: e.common.clone(), kind: k }; - let list = cadcraft_render::build_entities( - &st.doc, - std::iter::once(&ent), - &cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() }, - ); - draw_list(&painter, &xf, &list, bg, false); - } - painter.extend(Shape::dashed_line(&[xf.to_screen(g.base), xf.to_screen(c)], Stroke::new(1.0, t.canvas_ink), 4.0, 3.0)); - } // Rubber band preview of the active command. if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) { let ents = app.session.preview(c); @@ -1025,7 +967,7 @@ fn override_cursor(app: &mut CadApp, raw: Vec2, xf: &Xf) { let cycle = snap_cycle(app, raw, ap); let prompt = app.session.current_prompt(); let wants_point = prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); - if (over.is_none() && cycle.is_none()) || !wants_point || app.canvas.hot_grip.is_some() || app.session.pending_window.is_some() { + if (over.is_none() && cycle.is_none()) || !wants_point || app.session.pending_window.is_some() { return; } let base = prompt.as_ref().and_then(|p| p.base); @@ -1241,8 +1183,33 @@ fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { p.text(pill.center(), egui::Align2::CENTER_CENTER, crate::tl!("WCS ⌄"), crate::theme::small(), t.canvas_ink); } -/// The grip of a selected object under the cursor, if any. -fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option { +/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts +/// editing it, with the other hot grips when it is one of them. +fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) { + let hot = &mut app.canvas.hot_grips; + if shift { + match hot.iter().position(|x| *x == g) { + Some(i) => { + hot.remove(i); + } + None => hot.push(g), + } + return; + } + if !hot.contains(&g) { + hot.clear(); + } + hot.retain(|x| *x != g); + hot.insert(0, g); + let hs = hot.clone(); + if let Err(e) = app.session.start_grip_edit(&hs) { + app.canvas.hot_grips.clear(); + app.session.echo(e.to_string()); + } +} + +/// The grip of a selected object under the cursor, if any: (object, grip index). +fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option<(Handle, usize)> { let hp = hover?; let st = app.session.state().ok()?; let gs = app.session.settings.gripsize as f32 + 3.0; @@ -1254,7 +1221,7 @@ fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option { for (i, g) in e.kind.grips().into_iter().enumerate() { let sp = xf.to_screen(g); if (sp.x - hp.x).abs() <= gs && (sp.y - hp.y).abs() <= gs { - return Some(HotGrip { handle: *h, index: i, base: g, mode: cadcraft_engine::grips::GripMode::Stretch }); + return Some((*h, i)); } } } diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 2080c138..aa24bfa9 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -27,10 +27,10 @@ pub const MAX_LINES: usize = 12; const LINE_H: f32 = 16.0; /// The Dynamic Input frame for the prompt on screen, or `None` when the value boxes are not live -/// (Dynamic Input off, no point prompt, a hot grip or a pending selection window). +/// (Dynamic Input off, no point prompt or a pending selection window). pub fn dyn_frame(app: &CadApp) -> Option { let st = &app.session.settings; - if !st.dynmode || app.canvas.hot_grip.is_some() || app.session.pending_window.is_some() { + if !st.dynmode || app.session.pending_window.is_some() { return None; } let p = app.session.current_prompt().filter(|p| p.accept.point && !p.accept.select)?; @@ -136,40 +136,6 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { let selection = app.session.selection(); app.cmd.watch_selection(&selection); let mac = ctx.os() == egui::os::OperatingSystem::Mac; - // A hot grip takes Space/Enter (cycle mode, or apply a typed point) and Escape. - if app.canvas.hot_grip.is_some() { - for ev in &events { - match ev { - egui::Event::Text(t) if t != " " && t != "\t" => app.cmd.buffer.push_str(t), - egui::Event::Key { key: Key::Backspace, pressed: true, .. } => { - app.cmd.buffer.pop(); - } - egui::Event::Key { key: Key::Escape, pressed: true, .. } => { - app.canvas.hot_grip = None; - app.cmd.buffer.clear(); - app.session.echo("*Cancel*"); - } - egui::Event::Key { key: Key::Enter | Key::Space, pressed: true, .. } => { - let typed = std::mem::take(&mut app.cmd.buffer); - if typed.trim().is_empty() { - if let Some(g) = app.canvas.hot_grip.as_mut() { - g.next_mode(); - let l = g.label(); - app.session.echo(l); - } - } else { - let base = app.canvas.hot_grip.map(|g| g.base).unwrap_or_default(); - match cadcraft_engine::prompt::parse_point_with(&typed, base, &app.session.angle_settings()) { - Some(p) => crate::canvas::apply_hot_grip(app, p), - None => app.session.echo("Requires a point (x,y, @dx,dy or @d {} - } - } - return; - } let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number); for ev in events { match ev { @@ -295,75 +261,69 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let mut x = bar.left() + 30.0; let prompt = app.session.current_prompt(); let font = crate::theme::body(); - if let Some(g) = app.canvas.hot_grip { - let gl = p.layout_no_wrap(crate::i18n::t(g.label()).to_string(), font.clone(), t.text); - let gw = gl.size().x; - p.galley(pos2(x, bar.center().y - gl.size().y / 2.0), gl, t.text); - x += gw + 6.0; - } else { - match &prompt { - Some(pr) => { - let name = app.session.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default(); - let g = p.layout_no_wrap(format!("{name} "), font.clone(), t.text_faint); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); - x += gw; - let g = p.layout_no_wrap(crate::i18n::t(&pr.message).to_string(), font.clone(), t.text); + match &prompt { + Some(pr) => { + // The grip modes name themselves ("** STRETCH **…"). + let name = app.session.running.as_ref().filter(|r| r.id != "grip").map(|r| r.id.to_ascii_uppercase() + " ").unwrap_or_default(); + let g = p.layout_no_wrap(name, font.clone(), t.text_faint); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); + x += gw; + let g = p.layout_no_wrap(crate::i18n::t(&pr.message).to_string(), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + if !pr.keywords.is_empty() { + let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; - if !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - let kws = pr.keywords.clone(); - for (i, k) in kws.iter().enumerate() { - let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword); - let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); - let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); - if resp.hovered() { - p.rect_filled(r, 2.0, t.cmd_keyword_hover); - } - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.cmd_keyword); - x += gw; - if resp.clicked() { - let _ = app.session.input(Input::Keyword(k.clone())); - } - if i + 1 < kws.len() { - let g = p.layout_no_wrap("/".into(), font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - } + let kws = pr.keywords.clone(); + for (i, k) in kws.iter().enumerate() { + let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword); + let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); + let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); + if resp.hovered() { + p.rect_filled(r, 2.0, t.cmd_keyword_hover); } - let g = p.layout_no_wrap("]".into(), font.clone(), t.text); let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.cmd_keyword); x += gw; + if resp.clicked() { + let _ = app.session.input(Input::Keyword(k.clone())); + } + if i + 1 < kws.len() { + let g = p.layout_no_wrap("/".into(), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + } } - if let Some(d) = &pr.default { - let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - } - let g = p.layout_no_wrap(": ".into(), font.clone(), t.text); + let g = p.layout_no_wrap("]".into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; } - None => { - if app.cmd.buffer.is_empty() { - p.text( - pos2(x, bar.center().y), - egui::Align2::LEFT_CENTER, - crate::tl!("Type a command"), - egui::FontId::proportional(12.5), - t.text_faint, - ); - } + if let Some(d) = &pr.default { + let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + } + let g = p.layout_no_wrap(": ".into(), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + } + None => { + if app.cmd.buffer.is_empty() { + p.text( + pos2(x, bar.center().y), + egui::Align2::LEFT_CENTER, + crate::tl!("Type a command"), + egui::FontId::proportional(12.5), + t.text_faint, + ); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7991d2ec..3abed25c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -12,7 +12,7 @@ //! - `ui.elements {label?, role?, focused?}`: observed visible controls and actual egui focus //! - `ui.menu.list`: the menu tree; `ui.menu.invoke {command}`: like choosing a menu item //! - `ui.pointer {x, y, space?: "world"|"screen", button?: left|right, action?: click|move}` -//! - `ui.click {x, y}`, `ui.move {x, y}`: real egui pointer input in screen points +//! - `ui.click {x, y, shift?, ctrl?}`, `ui.move {x, y}`: real egui pointer input in screen points //! - `ui.key {key, cmd?, shift?, alt?}`, `ui.text {text}`: synthetic keyboard input //! - `ui.set {...UiState fields}`, `ui.resize {width, height}` //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless @@ -354,9 +354,13 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let modifiers = egui::Modifiers { shift: p.get("shift").and_then(Value::as_bool).unwrap_or(false), ..Default::default() }; + let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), ..Default::default() }; app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers }); + if modifiers.any() { + app.synthetic.push(egui::Event::PointerMoved(pos)); + } } ok(Value::Null) } diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3b704dc7..32dfb1d7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -308,6 +308,7 @@ Grid Display Відображення сітки Grid On (F7) Увімкнути сітку (F7) Grid spacing Крок сітки + Grip Edit Редагування ручками Grip Mirror Дзеркальне відображення ручками Grip Rotate Поворот ручками Grip Scale Масштабування ручками diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64246303..b5bad158 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -428,6 +428,12 @@ impl CadApp { if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { raw.events.push(egui::Event::PointerMoved(*p)); } + // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. + match self.synthetic.first() { + Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + _ => {} + } let n = n.min(self.synthetic.len()); raw.events.extend(self.synthetic.drain(..n)); } diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 7211b832..afad7073 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -252,7 +252,6 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let active = app.session.settings.qpmode && !sel.is_empty() && app.session.running.is_none() - && app.canvas.hot_grip.is_none() && !app.ui.start_tab && app.ui.dialog.as_deref() != Some("qselect"); let mut st: QpState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ac427f51..81e16e65 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,7 +32,7 @@ The server waits up to 60 s for the app to answer a request. After that it repli | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | -| `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). | +| `ui.click` / `ui.move` | `{x, y, button?, shift?, ctrl?}` | Real egui pointer events in screen points (reach every widget). | | `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). | | `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. | | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |