mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
65d5ee48d3
commit
608aa225a7
12 files changed
+844
-185
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod edit;
|
||||
mod exchange;
|
||||
pub mod file;
|
||||
mod gripcmds;
|
||||
mod griphot;
|
||||
pub(crate) mod groups;
|
||||
mod hatch;
|
||||
mod inquiry;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
Grid Display Відображення сітки
|
||||
Grid On (F7) Увімкнути сітку (F7)
|
||||
Grid spacing Крок сітки
|
||||
Grip Edit Редагування ручками
|
||||
Grip Mirror Дзеркальне відображення ручками
|
||||
Grip Rotate Поворот ручками
|
||||
Grip Scale Масштабування ручками
|
||||
|
||||
|
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in new issue
Block a user