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 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:22:48 -04:00
1 parent 65d5ee48d3
commit 608aa225a7
12 files changed
+844 -185

No files matched your search

+115 -3
View File
@@ -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> {
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<Value>
}
fn run_grip_stretch(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() }))
}
fn run_grip(_s: &mut Session, _p: &Value) -> Result<Value> {
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<Vec<Handle>> {
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<Value> {
+410
View File
@@ -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<Handle>,
base: Vec2,
mode: GripMode,
copy: bool,
ask: Ask,
/// The drawing before each edit made in Copy mode (Undo).
undo: Vec<Arc<Drawing>>,
}
/// The `GRIP` command's machine: select grips, then edit them.
pub(crate) fn factory(s: &Session) -> Result<Box<dyn Interactive>> {
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<f64>) -> Result<f64> {
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<Self> {
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<Mat3> {
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<Mat3>) -> Result<Step> {
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<Step> {
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<Step> {
let rotate = self.mode == GripMode::Rotate;
let au = s.angle_settings();
let value = |t: &str| -> Result<f64> {
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<f64> {
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<Step> {
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 <edit the hot grips>" };
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<Step> {
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<EntityKind> {
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<EntityKind> = 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(())
}
}
+1
View File
@@ -13,6 +13,7 @@ mod edit;
mod exchange;
pub mod file;
mod gripcmds;
mod griphot;
pub(crate) mod groups;
mod hatch;
mod inquiry;
+5
View File
@@ -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<dyn Interactive>) -> Box<dyn Interactive> {
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))
}
+194
View File
@@ -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());
}
+51 -84
View File
@@ -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<DisplayList>,
@@ -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<HotGrip>,
/// 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<Pos2>) -> Option<HotGrip> {
/// 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<Pos2>) -> 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<Pos2>) -> Option<HotGrip> {
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));
}
}
}
+54 -94
View File
@@ -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<crate::dyninput::Frame> {
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<a)."),
}
}
}
_ => {}
}
}
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,
);
}
}
}
+6 -2
View File
@@ -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)
}
+1
View File
@@ -308,6 +308,7 @@
Grid Display Відображення сітки
Grid On (F7) Увімкнути сітку (F7)
Grid spacing Крок сітки
Grip Edit Редагування ручками
Grip Mirror Дзеркальне відображення ручками
Grip Rotate Поворот ручками
Grip Scale Масштабування ручками
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
308
309
310
311
312
313
314
+6
View File
@@ -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));
}
-1
View File
@@ -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();
+1 -1
View File
@@ -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). |