mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Grip dimensional input: length, angle and radius fields while stretching a grip, DYNDIVIS/DYNDIGRIP (part of #519, stacked on #440) (#527)
Squash of PR #527 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
57f70b6b46
commit
c390364a16
10 files changed
+788
-3
No files matched your search
@@ -379,7 +379,11 @@ impl Interactive for GripEdit {
|
||||
for (n, a) in self.actions(s).into_iter().enumerate() {
|
||||
p.keywords.insert(at + n, a.keyword().to_string());
|
||||
}
|
||||
p
|
||||
// One hot grip being stretched: its dimensions can be typed (grip dimensional input).
|
||||
match self.hot.as_slice() {
|
||||
[g] if self.mode == GripMode::Stretch => p.grip(*g),
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Grip dimensional input: while one grip is stretched, Dynamic Input shows the dimensions the
|
||||
//! stretch changes (a line's length and angle, a polyline segment's, a circle's radius) as value
|
||||
//! fields, and a typed value places the grip.
|
||||
//!
|
||||
//! Which dimensions show follows DYNDIVIS (0: the first, 1: the first two, 2: all of DYNDIGRIP's
|
||||
//! bits: 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius). Each
|
||||
//! dimension is measured from an anchor that stays put (the line's other end, the neighbouring
|
||||
//! vertex, the circle's centre). Typed values lock: one locked value holds the grip to a circle or
|
||||
//! a ray around its anchor, two pin it to their crossing.
|
||||
//!
|
||||
//! The UI reads [`grip_dims`] and sends the resolved point as a plain point input, so the edit
|
||||
//! itself is the GRIP command's.
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::{TAU, Vec2};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// What a dimension measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripDimKind {
|
||||
/// The resulting length from the anchor (DYNDIGRIP 1).
|
||||
Length,
|
||||
/// The change in that length (DYNDIGRIP 2).
|
||||
LengthChange,
|
||||
/// The absolute angle from the anchor (DYNDIGRIP 4).
|
||||
Angle,
|
||||
/// The change in that angle (DYNDIGRIP 8).
|
||||
AngleChange,
|
||||
/// A circle's radius (DYNDIGRIP 16).
|
||||
Radius,
|
||||
}
|
||||
|
||||
impl GripDimKind {
|
||||
/// The DYNDIGRIP bit that shows it.
|
||||
pub fn bit(self) -> u32 {
|
||||
match self {
|
||||
GripDimKind::Length => 1,
|
||||
GripDimKind::LengthChange => 2,
|
||||
GripDimKind::Angle => 4,
|
||||
GripDimKind::AngleChange => 8,
|
||||
GripDimKind::Radius => 16,
|
||||
}
|
||||
}
|
||||
/// The value is an angle (radians), not a length.
|
||||
pub fn is_angle(self) -> bool {
|
||||
matches!(self, GripDimKind::Angle | GripDimKind::AngleChange)
|
||||
}
|
||||
}
|
||||
|
||||
/// One dimension of a grip being stretched.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GripDim {
|
||||
pub kind: GripDimKind,
|
||||
/// The point it is measured from, which the stretch doesn't move.
|
||||
pub anchor: Vec2,
|
||||
/// The length or angle before the stretch (what the change dimensions are measured against).
|
||||
pub original: f64,
|
||||
}
|
||||
|
||||
/// Where the grip may go once a value is locked.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Locus {
|
||||
Circle(Vec2, f64),
|
||||
Ray(Vec2, Vec2),
|
||||
}
|
||||
|
||||
/// An angle in [0, 2π).
|
||||
fn wrap(a: f64) -> f64 {
|
||||
let r = a.rem_euclid(TAU);
|
||||
if r >= TAU { 0.0 } else { r }
|
||||
}
|
||||
|
||||
/// An angle in (-π, π].
|
||||
fn wrap_signed(a: f64) -> f64 {
|
||||
let r = wrap(a);
|
||||
if r > std::f64::consts::PI { r - TAU } else { r }
|
||||
}
|
||||
|
||||
impl GripDim {
|
||||
fn length(kind: GripDimKind, anchor: Vec2, grip: Vec2) -> Self {
|
||||
GripDim { kind, anchor, original: anchor.dist(grip) }
|
||||
}
|
||||
fn angle(kind: GripDimKind, anchor: Vec2, grip: Vec2) -> Self {
|
||||
GripDim { kind, anchor, original: wrap(anchor.angle_to(grip)) }
|
||||
}
|
||||
|
||||
/// The value it reads with the grip at `p` (radians for angles).
|
||||
pub fn value(&self, p: Vec2) -> f64 {
|
||||
let (len, ang) = (self.anchor.dist(p), wrap(self.anchor.angle_to(p)));
|
||||
match self.kind {
|
||||
GripDimKind::Length | GripDimKind::Radius => len,
|
||||
GripDimKind::LengthChange => len - self.original,
|
||||
GripDimKind::Angle => ang,
|
||||
GripDimKind::AngleChange => wrap_signed(ang - self.original),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the grip may go once this dimension is locked at `v`; `None` for a length that isn't
|
||||
/// positive.
|
||||
fn locus(&self, v: f64) -> Option<Locus> {
|
||||
let circle = |r: f64| (r.is_finite() && r > 1e-12).then_some(Locus::Circle(self.anchor, r));
|
||||
let ray = |a: f64| a.is_finite().then(|| Locus::Ray(self.anchor, Vec2::from_angle(a)));
|
||||
match self.kind {
|
||||
GripDimKind::Length | GripDimKind::Radius => circle(v),
|
||||
GripDimKind::LengthChange => circle(self.original + v),
|
||||
GripDimKind::Angle => ray(v),
|
||||
GripDimKind::AngleChange => ray(self.original + v),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a typed value: a distance, or an angle (absolute in the drawing's angle settings, or
|
||||
/// a signed change in its angle units).
|
||||
pub fn parse(&self, s: &Session, text: &str) -> Option<f64> {
|
||||
let au = s.angle_settings();
|
||||
let v = match self.kind {
|
||||
GripDimKind::Angle => au.direction(text),
|
||||
GripDimKind::AngleChange => au.rotation(text),
|
||||
_ => crate::units::parse_distance(text),
|
||||
};
|
||||
v.filter(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
impl Locus {
|
||||
/// The closest point of the locus to `p`.
|
||||
fn closest(self, p: Vec2) -> Vec2 {
|
||||
match self {
|
||||
Locus::Circle(c, r) => {
|
||||
let d = p - c;
|
||||
let l = d.len();
|
||||
c + if l > 1e-12 { d * (r / l) } else { Vec2::X * r }
|
||||
}
|
||||
Locus::Ray(o, dir) => o + dir * (p - o).dot(dir).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points where two loci cross.
|
||||
fn crossings(self, other: Locus) -> Vec<Vec2> {
|
||||
match (self, other) {
|
||||
(Locus::Circle(c, r), Locus::Ray(o, d)) | (Locus::Ray(o, d), Locus::Circle(c, r)) => {
|
||||
// |o + t d - c| = r, t >= 0.
|
||||
let f = o - c;
|
||||
let b = f.dot(d);
|
||||
let disc = b * b - (f.dot(f) - r * r);
|
||||
if disc < 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let s = disc.sqrt();
|
||||
[-b - s, -b + s].into_iter().filter(|t| *t >= -1e-9).map(|t| o + d * t).collect()
|
||||
}
|
||||
(Locus::Circle(c1, r1), Locus::Circle(c2, r2)) => {
|
||||
let d = c2 - c1;
|
||||
let l = d.len();
|
||||
if l < 1e-12 || l > r1 + r2 + 1e-9 || l < (r1 - r2).abs() - 1e-9 {
|
||||
return Vec::new();
|
||||
}
|
||||
let a = (r1 * r1 - r2 * r2 + l * l) / (2.0 * l);
|
||||
let h = (r1 * r1 - a * a).max(0.0).sqrt();
|
||||
let m = c1 + d * (a / l);
|
||||
let n = d.perp() * (h / l);
|
||||
vec![m + n, m - n]
|
||||
}
|
||||
(Locus::Ray(o1, d1), Locus::Ray(o2, d2)) => {
|
||||
let den = d1.cross(d2);
|
||||
if den.abs() < 1e-12 {
|
||||
return Vec::new();
|
||||
}
|
||||
let w = o2 - o1;
|
||||
let (t, u) = (w.cross(d2) / den, w.cross(d1) / den);
|
||||
if t >= -1e-9 && u >= -1e-9 { vec![o1 + d1 * t] } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimensions to show for the grip the current prompt stretches, in cycle order (Tab), as
|
||||
/// DYNDIVIS and DYNDIGRIP ask; empty when no single grip is being stretched or Dynamic Input
|
||||
/// is off.
|
||||
pub fn grip_dims(s: &Session) -> Vec<GripDim> {
|
||||
if !s.settings.dynmode {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some((h, i)) = s.current_prompt().and_then(|p| p.grip) else { return Vec::new() };
|
||||
let Some(e) = s.doc().ok().and_then(|d| d.entity(h)) else { return Vec::new() };
|
||||
let all = dims_of(&e.kind, i);
|
||||
let st = &s.settings;
|
||||
match st.dyndivis {
|
||||
0 => all.into_iter().take(1).collect(),
|
||||
1 => all.into_iter().take(2).collect(),
|
||||
_ => all.into_iter().filter(|d| st.dyndigrip & d.kind.bit() != 0).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dimension a grip's stretch changes, in cycle order.
|
||||
fn dims_of(k: &EntityKind, grip: usize) -> Vec<GripDim> {
|
||||
use GripDimKind::*;
|
||||
let grips = k.grips();
|
||||
let Some(&g) = grips.get(grip) else { return Vec::new() };
|
||||
let mut anchors: Vec<Vec2> = Vec::new();
|
||||
match k {
|
||||
// An end of a line: measured from the other end (the middle grip moves the whole line).
|
||||
EntityKind::Line(l) => match grip {
|
||||
0 => anchors.push(l.b.xy()),
|
||||
2 => anchors.push(l.a.xy()),
|
||||
_ => {}
|
||||
},
|
||||
// A quadrant of a circle: its radius.
|
||||
EntityKind::Circle(c) if grip > 0 => {
|
||||
let o = c.center.xy();
|
||||
return vec![GripDim::length(Radius, o, g), GripDim::length(LengthChange, o, g)];
|
||||
}
|
||||
// A vertex of a polyline: the straight segments on either side.
|
||||
EntityKind::LwPolyline(p) if grip.is_multiple_of(2) => {
|
||||
let n = p.vertices.len();
|
||||
let k = grip / 2;
|
||||
let straight = |seg: usize| p.vertices.get(seg).is_some_and(|v| v.bulge.abs() < 1e-12);
|
||||
let prev = if k > 0 {
|
||||
Some(k - 1)
|
||||
} else if p.closed && n > 2 {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if k + 1 < n {
|
||||
Some(k + 1)
|
||||
} else if p.closed && n > 2 {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(j) = prev.filter(|j| straight(*j))
|
||||
&& let Some(v) = p.vertices.get(j)
|
||||
{
|
||||
anchors.push(v.p);
|
||||
}
|
||||
if let Some(j) = next.filter(|_| straight(k))
|
||||
&& let Some(v) = p.vertices.get(j)
|
||||
{
|
||||
anchors.push(v.p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let anchors: Vec<Vec2> = anchors.into_iter().filter(|a| a.is_finite() && a.dist(g) > 1e-12).collect();
|
||||
let mut out: Vec<GripDim> = anchors.iter().map(|a| GripDim::length(Length, *a, g)).collect();
|
||||
out.extend(anchors.iter().map(|a| GripDim::length(LengthChange, *a, g)));
|
||||
// The angle of the first segment only (the others follow from the lengths).
|
||||
if let Some(a) = anchors.first() {
|
||||
out.push(GripDim::angle(Angle, *a, g));
|
||||
out.push(GripDim::angle(AngleChange, *a, g));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The grip's point for the cursor at `cursor` with the `locked` dimension values (index into
|
||||
/// `dims`, value): none locked follows the cursor; one holds it to that dimension's circle or ray;
|
||||
/// two or more pin it where the first two cross (the crossing nearest the cursor).
|
||||
pub fn resolve(dims: &[GripDim], locked: &[(usize, f64)], cursor: Vec2) -> Vec2 {
|
||||
let loci: Vec<Locus> = locked.iter().filter_map(|(i, v)| dims.get(*i)?.locus(*v)).take(2).collect();
|
||||
match loci.as_slice() {
|
||||
[] => cursor,
|
||||
[a] => a.closest(cursor),
|
||||
[a, b, ..] => a
|
||||
.crossings(*b)
|
||||
.into_iter()
|
||||
.filter(|p| p.is_finite())
|
||||
.min_by(|p, q| p.dist(cursor).total_cmp(&q.dist(cursor)))
|
||||
.unwrap_or_else(|| a.closest(cursor)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
a.dist(b) < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_end_measures_from_the_other_end() {
|
||||
let l = EntityKind::Line(cadcraft_doc::Line { a: Vec2::new(0.0, 0.0).to3(0.0), b: Vec2::new(10.0, 0.0).to3(0.0) });
|
||||
let d = dims_of(&l, 2);
|
||||
let kinds: Vec<_> = d.iter().map(|d| d.kind).collect();
|
||||
assert_eq!(kinds, [GripDimKind::Length, GripDimKind::LengthChange, GripDimKind::Angle, GripDimKind::AngleChange]);
|
||||
assert!(d.iter().all(|d| d.anchor == Vec2::ZERO));
|
||||
let p = Vec2::new(0.0, 12.0);
|
||||
assert!((d[0].value(p) - 12.0).abs() < 1e-12);
|
||||
assert!((d[1].value(p) - 2.0).abs() < 1e-12);
|
||||
assert!((d[2].value(p) - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
assert!((d[3].value(Vec2::new(0.0, -3.0)) + std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
// The middle grip moves the line: nothing to measure.
|
||||
assert!(dims_of(&l, 1).is_empty());
|
||||
assert!(dims_of(&l, 9).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locks_hold_and_pin_the_grip() {
|
||||
let o = Vec2::ZERO;
|
||||
let g = Vec2::new(10.0, 0.0);
|
||||
let d = [GripDim::length(GripDimKind::Length, o, g), GripDim::angle(GripDimKind::Angle, o, g)];
|
||||
let c = Vec2::new(3.0, 4.0);
|
||||
assert_eq!(resolve(&d, &[], c), c);
|
||||
// Length 20: on the circle, toward the cursor.
|
||||
assert!(close(resolve(&d, &[(0, 20.0)], c), Vec2::new(12.0, 16.0)));
|
||||
// Angle 90°: on the ray.
|
||||
assert!(close(resolve(&d, &[(1, std::f64::consts::FRAC_PI_2)], c), Vec2::new(0.0, 4.0)));
|
||||
// Both: the point.
|
||||
assert!(close(resolve(&d, &[(0, 20.0), (1, std::f64::consts::FRAC_PI_2)], c), Vec2::new(0.0, 20.0)));
|
||||
// A length that isn't positive locks nothing.
|
||||
assert_eq!(resolve(&d, &[(0, -1.0)], c), c);
|
||||
// Two lengths from different anchors: where the circles cross, nearest the cursor.
|
||||
let d2 = [GripDim::length(GripDimKind::Length, Vec2::ZERO, g), GripDim::length(GripDimKind::Length, Vec2::new(10.0, 0.0), g)];
|
||||
assert!(close(resolve(&d2, &[(0, 5.0_f64.hypot(5.0)), (1, 5.0_f64.hypot(5.0))], Vec2::new(5.0, -1.0)), Vec2::new(5.0, -5.0)));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod pointmod;
|
||||
@@ -324,6 +325,12 @@ pub struct Settings {
|
||||
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
|
||||
pub view_layer_snapshot: bool,
|
||||
pub dynmode: bool,
|
||||
/// DYNDIGRIP: which dimensions grip dimensional input shows while a grip is stretched (bits:
|
||||
/// 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius); read
|
||||
/// when DYNDIVIS = 2.
|
||||
pub dyndigrip: u32,
|
||||
/// DYNDIVIS: how many of them: 0 the first, 1 the first two (default), 2 all DYNDIGRIP asks for.
|
||||
pub dyndivis: u8,
|
||||
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
|
||||
/// polar `distance<angle` (the default).
|
||||
pub dynpi_cartesian: bool,
|
||||
@@ -387,6 +394,8 @@ impl Default for Settings {
|
||||
zoomfactor: 60,
|
||||
view_layer_snapshot: true,
|
||||
dynmode: true,
|
||||
dyndigrip: 31,
|
||||
dyndivis: 1,
|
||||
dynpi_cartesian: false,
|
||||
dynpi_absolute: false,
|
||||
dynprompt: true,
|
||||
|
||||
@@ -41,11 +41,20 @@ pub struct Prompt {
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
|
||||
/// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grip: Option<(Handle, usize)>,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false }
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None }
|
||||
}
|
||||
/// The grip being stretched (the `grip` field).
|
||||
pub fn grip(mut self, g: (Handle, usize)) -> Self {
|
||||
self.grip = Some(g);
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
@@ -24,6 +24,8 @@ const SESSION_VARS: &[&str] = &[
|
||||
"UNDOCTL",
|
||||
"UNDOMARKS",
|
||||
"GRIDMAJOR",
|
||||
"DYNDIGRIP",
|
||||
"DYNDIVIS",
|
||||
"DYNMODE",
|
||||
"DYNPIFORMAT",
|
||||
"DYNPICOORDS",
|
||||
@@ -71,6 +73,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"SNAPUNIT" => json!([st.snapunit.x, st.snapunit.y]),
|
||||
"GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]),
|
||||
"GRIDMAJOR" => json!(st.gridmajor),
|
||||
"DYNDIGRIP" => json!(st.dyndigrip),
|
||||
"DYNDIVIS" => json!(st.dyndivis),
|
||||
"DYNMODE" => json!(i32::from(st.dynmode)),
|
||||
"DYNPIFORMAT" => json!(i32::from(st.dynpi_cartesian)),
|
||||
"DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)),
|
||||
@@ -209,6 +213,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"SNAPUNIT" => st.snapunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?,
|
||||
"GRIDUNIT" => st.gridunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?,
|
||||
"GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32,
|
||||
"DYNDIGRIP" => st.dyndigrip = as_i(v).filter(|i| (0..=31).contains(i)).and_then(|i| u32::try_from(i).ok()).ok_or_else(bad)?,
|
||||
"DYNDIVIS" => st.dyndivis = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?,
|
||||
"DYNMODE" => st.dynmode = as_bool(v).ok_or_else(bad)?,
|
||||
"DYNPIFORMAT" => st.dynpi_cartesian = as_bool(v).ok_or_else(bad)?,
|
||||
"DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Grip dimensional input (#519): while one grip is stretched, the dimensions it changes can be
|
||||
//! typed (DYNDIVIS, DYNDIGRIP).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::grip_dims::{GripDimKind, grip_dims, resolve};
|
||||
use cadcraft_engine::{Input, sysvars};
|
||||
use serde_json::json;
|
||||
|
||||
fn handle(r: serde_json::Value) -> Handle {
|
||||
Handle::parse_hex(r.get("handle").unwrap_or(&r["handles"][0]).as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn grip_index(s: &Session, h: Handle, x: f64, y: f64) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(x, y), 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
fn kinds(s: &Session) -> Vec<GripDimKind> {
|
||||
grip_dims(s).iter().map(|d| d.kind).collect()
|
||||
}
|
||||
|
||||
/// Lock `values` (by dimension index), resolve with the cursor at `cursor` and place the grip.
|
||||
fn place(s: &mut Session, values: &[(usize, f64)], cursor: Vec2) {
|
||||
let dims = grip_dims(s);
|
||||
let p = resolve(&dims, values, cursor);
|
||||
s.input(Input::Point(p)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_length_and_angle_place_a_line_end() {
|
||||
let mut s = Session::new();
|
||||
let h = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
|
||||
let end = grip_index(&s, h, 10.0, 0.0);
|
||||
s.start_grip_edit(&[(h, end)]).unwrap();
|
||||
// DYNDIVIS = 1: the first two, the resulting length and its change.
|
||||
assert_eq!(kinds(&s), [GripDimKind::Length, GripDimKind::LengthChange]);
|
||||
let dims = grip_dims(&s);
|
||||
assert_eq!(dims[0].anchor, Vec2::ZERO);
|
||||
assert!((dims[0].value(Vec2::new(0.0, 12.0)) - 12.0).abs() < 1e-12);
|
||||
// Typed: the length reads 20 and the angle follows the cursor.
|
||||
assert!((dims[0].parse(&s, "20").unwrap() - 20.0).abs() < 1e-12);
|
||||
place(&mut s, &[(0, 20.0)], Vec2::new(3.0, 4.0));
|
||||
assert!(s.running.is_none());
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => {
|
||||
assert!(l.a.xy().near(Vec2::ZERO, 1e-9));
|
||||
assert!(l.b.xy().near(Vec2::new(12.0, 16.0), 1e-9), "{:?}", l.b);
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
|
||||
// DYNDIVIS = 2 shows what DYNDIGRIP asks for: angle only, then length and angle typed.
|
||||
s.execute("setvar", &json!({"name": "DYNDIVIS", "value": 2})).unwrap();
|
||||
s.execute("setvar", &json!({"name": "dyndigrip", "value": 5})).unwrap();
|
||||
let end = grip_index(&s, h, 12.0, 16.0);
|
||||
s.start_grip_edit(&[(h, end)]).unwrap();
|
||||
assert_eq!(kinds(&s), [GripDimKind::Length, GripDimKind::Angle]);
|
||||
let angle = grip_dims(&s)[1].parse(&s, "90").unwrap();
|
||||
place(&mut s, &[(0, 5.0), (1, angle)], Vec2::new(40.0, 40.0));
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => assert!(l.b.xy().near(Vec2::new(0.0, 5.0), 1e-9), "{:?}", l.b),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_single_stretched_grip_has_dimensions() {
|
||||
let mut s = Session::new();
|
||||
let h = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
|
||||
let mid = grip_index(&s, h, 5.0, 0.0);
|
||||
s.start_grip_edit(&[(h, mid)]).unwrap();
|
||||
assert!(grip_dims(&s).is_empty(), "the middle grip moves the line");
|
||||
s.cancel();
|
||||
s.start_grip_edit(&[(h, 0), (h, 2)]).unwrap();
|
||||
assert!(grip_dims(&s).is_empty(), "several hot grips");
|
||||
s.cancel();
|
||||
s.start_grip_edit(&[(h, 2)]).unwrap();
|
||||
assert!(!grip_dims(&s).is_empty());
|
||||
// Enter: MOVE mode, no dimensions.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(grip_dims(&s).is_empty());
|
||||
s.cancel();
|
||||
// Dynamic Input off: none.
|
||||
s.settings.dynmode = false;
|
||||
s.start_grip_edit(&[(h, 2)]).unwrap();
|
||||
assert!(grip_dims(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_radius_and_polyline_segments() {
|
||||
let mut s = Session::new();
|
||||
let c = handle(s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap());
|
||||
let q = grip_index(&s, c, 5.0, 0.0);
|
||||
s.start_grip_edit(&[(c, q)]).unwrap();
|
||||
assert_eq!(kinds(&s), [GripDimKind::Radius, GripDimKind::LengthChange]);
|
||||
// Radius change +2: radius 7.
|
||||
place(&mut s, &[(1, 2.0)], Vec2::new(0.0, 1.0));
|
||||
match &s.doc().unwrap().entity(c).unwrap().kind {
|
||||
EntityKind::Circle(k) => assert!((k.radius - 7.0).abs() < 1e-9, "{k:?}"),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
|
||||
let p = handle(s.execute("pline", &json!({ "points": [[0, 0], [10, 0], [10, 10]] })).unwrap());
|
||||
let v = grip_index(&s, p, 10.0, 0.0);
|
||||
s.start_grip_edit(&[(p, v)]).unwrap();
|
||||
// The two segments on either side of the vertex.
|
||||
let dims = grip_dims(&s);
|
||||
assert_eq!(kinds(&s), [GripDimKind::Length, GripDimKind::Length]);
|
||||
assert_eq!((dims[0].anchor, dims[1].anchor), (Vec2::ZERO, Vec2::new(10.0, 10.0)));
|
||||
// Both lengths typed: the vertex goes where they agree, on the cursor's side.
|
||||
let r = 50.0_f64.sqrt();
|
||||
place(&mut s, &[(0, r), (1, r)], Vec2::new(9.0, 1.0));
|
||||
match &s.doc().unwrap().entity(p).unwrap().kind {
|
||||
EntityKind::LwPolyline(k) => assert!(k.vertices[1].p.near(Vec2::new(5.0, 5.0), 1e-9), "{:?}", k.vertices[1].p),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyndigrip_and_dyndivis_are_checked_settings() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(sysvars::get(&s, "DYNDIGRIP"), Some(json!(31)));
|
||||
assert_eq!(sysvars::get(&s, "DYNDIVIS"), Some(json!(1)));
|
||||
for (name, bad) in [("DYNDIGRIP", json!(32)), ("DYNDIGRIP", json!(-1)), ("DYNDIVIS", json!(3)), ("DYNDIVIS", json!("x"))] {
|
||||
assert!(s.execute("setvar", &json!({"name": name, "value": bad})).is_err(), "{name} {bad}");
|
||||
}
|
||||
s.execute("setvar", &json!({"name": "DYNDIVIS", "value": 0})).unwrap();
|
||||
assert_eq!(s.settings.dyndivis, 0);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ pub struct CanvasState {
|
||||
/// 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)>,
|
||||
/// Grip dimensional input: the value fields of the grip being stretched.
|
||||
pub grip_dyn: crate::grip_dyn::GripDyn,
|
||||
/// A GRIP edit was running last frame (its grips go cold when it ends).
|
||||
grip_editing: bool,
|
||||
/// The grip whose menu (Stretch, Add Vertex, Lengthen…) a right-click opened.
|
||||
@@ -692,6 +694,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
app.canvas.snap = None;
|
||||
app.canvas.track = None;
|
||||
}
|
||||
// Grip dimensional input: typed lengths and angles hold the grip and override snaps.
|
||||
let grip_held = crate::grip_dyn::constrain(app, eff);
|
||||
if let Some(q) = grip_held {
|
||||
eff = q;
|
||||
app.canvas.snap = None;
|
||||
app.canvas.track = None;
|
||||
}
|
||||
app.canvas.cursor = Some(eff);
|
||||
app.session.cursor = eff;
|
||||
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
|
||||
@@ -699,6 +708,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) {
|
||||
override_cursor(app, w, &xf);
|
||||
}
|
||||
// Typed grip dimensions win over snap overrides too.
|
||||
if let Some(q) = grip_held {
|
||||
app.canvas.cursor = Some(q);
|
||||
app.session.cursor = q;
|
||||
app.canvas.snap = None;
|
||||
app.session.cursor_deferred = None;
|
||||
}
|
||||
} else {
|
||||
app.canvas.cursor = None;
|
||||
app.canvas.snap = None;
|
||||
@@ -987,7 +1003,10 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if app.session.settings.dynmode
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
dynamic_input(app, &painter, hp);
|
||||
// A stretched grip shows its dimensions instead of the pointer boxes.
|
||||
if !crate::grip_dyn::draw(app, &painter) {
|
||||
dynamic_input(app, &painter, hp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No continuous repaint while hovering: input events repaint by themselves. Repainting every
|
||||
|
||||
@@ -74,6 +74,10 @@ pub fn capture_tab(ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
/// Tab on the command line: cycle the object snaps under the cursor, type the Dynamic Input
|
||||
/// separator (`,` or `<`) after a value, or complete a command name.
|
||||
fn tab(app: &mut CadApp) {
|
||||
// Grip dimensional input: Tab locks the typed value and moves to the next field.
|
||||
if crate::grip_dyn::tab(app) {
|
||||
return;
|
||||
}
|
||||
// Over a snap marker with nothing typed, Tab cycles the candidate snaps.
|
||||
if app.cmd.buffer.is_empty() && app.canvas.snap.is_some() {
|
||||
app.canvas.snap_tab = true;
|
||||
@@ -300,6 +304,10 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub fn submit(app: &mut CadApp) {
|
||||
// Grip dimensional input: the typed lengths and angles place the grip.
|
||||
if crate::grip_dyn::submit(app) {
|
||||
return;
|
||||
}
|
||||
// Dynamic Input: a split entry (`40,30`, `40<90`, `40,`) is completed from the cursor and
|
||||
// measured from the last point.
|
||||
if let Some(f) = dyn_frame(app) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Grip dimensional input at the cursor: while one grip is stretched, the dimensions it changes
|
||||
//! ([`cadcraft_engine::grip_dims`]) show as value fields beside the object in place of the pointer
|
||||
//! boxes. Typing goes into the active field; Tab locks a typed value and moves to the next field;
|
||||
//! locked values hold the grip; Enter places it there (the GRIP command gets a plain point).
|
||||
//!
|
||||
//! The command-line text is the active field's text, so Backspace and Escape behave as usual;
|
||||
//! text that isn't a value (a keyword such as `C` for Copy) goes to the command as typed.
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_engine::Input;
|
||||
use cadcraft_engine::grip_dims::{GripDim, GripDimKind, grip_dims, resolve};
|
||||
use cadcraft_geom::Vec2;
|
||||
use egui::{Color32, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The fields' state for the grip being stretched.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GripDyn {
|
||||
/// The grip the fields belong to; another grip starts over.
|
||||
grip: Option<(Handle, usize)>,
|
||||
/// The active field.
|
||||
pub active: usize,
|
||||
/// Locked fields: (field, value as typed).
|
||||
pub locked: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
/// The dimensions on show, after bringing the fields' state up to date with the prompt (empty when
|
||||
/// no grip is stretched or the value boxes aren't live).
|
||||
pub fn dims(app: &mut CadApp) -> Vec<GripDim> {
|
||||
let grip = crate::cmdline::dyn_frame(app).and_then(|_| app.session.current_prompt()).and_then(|p| p.grip);
|
||||
let st = &mut app.canvas.grip_dyn;
|
||||
if st.grip != grip {
|
||||
*st = GripDyn { grip, ..GripDyn::default() };
|
||||
}
|
||||
if grip.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dims = grip_dims(&app.session);
|
||||
if app.canvas.grip_dyn.active >= dims.len() {
|
||||
app.canvas.grip_dyn.active = 0;
|
||||
}
|
||||
dims
|
||||
}
|
||||
|
||||
/// The values that hold the grip: the locked ones, and the active field's typed value.
|
||||
fn values(app: &CadApp, dims: &[GripDim]) -> Vec<(usize, f64)> {
|
||||
let st = &app.canvas.grip_dyn;
|
||||
let mut out: Vec<(usize, f64)> = st
|
||||
.locked
|
||||
.iter()
|
||||
.filter(|(i, _)| *i != st.active || app.cmd.buffer.trim().is_empty())
|
||||
.filter_map(|(i, t)| Some((*i, dims.get(*i)?.parse(&app.session, t)?)))
|
||||
.collect();
|
||||
if let Some(d) = dims.get(st.active)
|
||||
&& let Some(v) = d.parse(&app.session, &app.cmd.buffer)
|
||||
{
|
||||
out.push((st.active, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The cursor held by the typed values, or `None` when nothing holds it.
|
||||
pub fn constrain(app: &mut CadApp, p: Vec2) -> Option<Vec2> {
|
||||
let dims = dims(app);
|
||||
let vals = values(app, &dims);
|
||||
(!vals.is_empty()).then(|| resolve(&dims, &vals, p)).filter(|q| q.is_finite())
|
||||
}
|
||||
|
||||
/// Tab: lock the active field's typed value and move to the next field. `false` when no grip
|
||||
/// fields are on show (Tab does its usual thing).
|
||||
pub fn tab(app: &mut CadApp) -> bool {
|
||||
let dims = dims(app);
|
||||
if dims.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let st = &mut app.canvas.grip_dyn;
|
||||
let typed = app.cmd.buffer.trim().to_owned();
|
||||
if dims.get(st.active).and_then(|d| d.parse(&app.session, &typed)).is_some() {
|
||||
st.locked.retain(|(i, _)| *i != st.active);
|
||||
st.locked.push((st.active, typed));
|
||||
app.cmd.buffer.clear();
|
||||
}
|
||||
st.active = (st.active + 1) % dims.len();
|
||||
true
|
||||
}
|
||||
|
||||
/// Enter: place the grip where the typed values put it. `false` when nothing was typed or the
|
||||
/// text isn't a value (Enter or the text then go to the command as usual).
|
||||
pub fn submit(app: &mut CadApp) -> bool {
|
||||
let dims = dims(app);
|
||||
if dims.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let typed = app.cmd.buffer.trim();
|
||||
if !typed.is_empty() && dims.get(app.canvas.grip_dyn.active).and_then(|d| d.parse(&app.session, typed)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let vals = values(app, &dims);
|
||||
if vals.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let cursor = app.canvas.cursor.unwrap_or(app.session.cursor);
|
||||
let p = resolve(&dims, &vals, cursor);
|
||||
app.cmd.buffer.clear();
|
||||
app.canvas.grip_dyn = GripDyn::default();
|
||||
if let Err(e) = app.session.input(Input::Point(p)) {
|
||||
app.session.echo(e.to_string());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn format(kind: GripDimKind, v: f64) -> String {
|
||||
if kind.is_angle() { format!("{}°", (v.to_degrees() * 10.0).round() / 10.0) } else { cadcraft_engine::units::format_distance(v, 2, 4) }
|
||||
}
|
||||
|
||||
/// Draw the fields for the stretched grip at the cursor; `false` when there are none (the
|
||||
/// pointer boxes show instead).
|
||||
pub fn draw(app: &mut CadApp, p: &egui::Painter) -> bool {
|
||||
let dims = dims(app);
|
||||
let (Some(xf), Some(c)) = (app.canvas.xf, app.canvas.cursor) else { return false };
|
||||
if dims.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let font = crate::theme::small();
|
||||
let line = Stroke::new(1.0, t.text_dim);
|
||||
let st = app.canvas.grip_dyn.clone();
|
||||
let cs = xf.to_screen(c);
|
||||
let mut offset_rank = 0.0;
|
||||
for (i, d) in dims.iter().enumerate() {
|
||||
let a = xf.to_screen(d.anchor);
|
||||
let active = i == st.active;
|
||||
let locked = st.locked.iter().find(|(j, _)| *j == i).map(|(_, s)| s.as_str());
|
||||
let text = if active && !app.cmd.buffer.is_empty() {
|
||||
app.cmd.buffer.clone()
|
||||
} else if let Some(l) = locked {
|
||||
l.to_owned()
|
||||
} else {
|
||||
format(d.kind, d.value(c))
|
||||
};
|
||||
// Where the field sits: beside the measured length, or on the angle's arc.
|
||||
let at = if d.kind.is_angle() {
|
||||
// The change in angle on a wider arc than the angle itself.
|
||||
let wide = if d.kind == GripDimKind::AngleChange { 2.2 } else { 1.0 };
|
||||
let r = a.distance(cs).clamp(24.0, 70.0) * 0.6 * wide;
|
||||
let a0 = -(if d.kind == GripDimKind::AngleChange { d.original } else { 0.0 }) as f32;
|
||||
let a1 = -(d.anchor.angle_to(c)) as f32;
|
||||
let mut sweep = a1 - a0;
|
||||
while sweep > std::f32::consts::PI {
|
||||
sweep -= std::f32::consts::TAU;
|
||||
}
|
||||
while sweep < -std::f32::consts::PI {
|
||||
sweep += std::f32::consts::TAU;
|
||||
}
|
||||
let pts: Vec<Pos2> = (0..=24).map(|k| a + egui::Vec2::angled(a0 + sweep * k as f32 / 24.0) * r).collect();
|
||||
p.add(Shape::line(pts, line));
|
||||
a + egui::Vec2::angled(a0 + sweep / 2.0) * (r + 24.0)
|
||||
} else {
|
||||
offset_rank += 1.0;
|
||||
let along = cs - a;
|
||||
let n = if along.length() > 1e-3 { vec2(-along.y, along.x) / along.length() } else { vec2(0.0, -1.0) };
|
||||
// Each further length sits farther out and farther along, clear of the one before.
|
||||
let off = n * (24.0 * offset_rank);
|
||||
p.line_segment([a + off, cs + off], line);
|
||||
p.line_segment([a + off * 0.2, a + off * 1.1], Stroke::new(1.0, t.text_faint));
|
||||
p.line_segment([cs + off * 0.2, cs + off * 1.1], Stroke::new(1.0, t.text_faint));
|
||||
a + off + along * (0.25 + 0.25 * offset_rank.min(2.0))
|
||||
};
|
||||
let live = locked.is_none() && !(active && !app.cmd.buffer.is_empty());
|
||||
field(p, at, &text, &font, active, locked.is_some(), live);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// One value field centred on `at`.
|
||||
fn field(p: &egui::Painter, at: Pos2, text: &str, font: &egui::FontId, active: bool, locked: bool, live: bool) {
|
||||
let t = Tokens::get();
|
||||
let g = p.layout_no_wrap(text.to_owned(), font.clone(), if live { t.text_faint } else { t.text });
|
||||
let lock_w = if locked { 11.0 } else { 0.0 };
|
||||
let r = Rect::from_center_size(at, vec2((g.size().x + 10.0 + lock_w).max(40.0), g.size().y + 6.0));
|
||||
p.rect_filled(r, 2.0, t.chrome_dark);
|
||||
p.rect_stroke(r, 2.0, if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.text_faint) }, egui::StrokeKind::Inside);
|
||||
let gw = g.size().x;
|
||||
p.galley(pos2(r.left() + 5.0, r.center().y - g.size().y / 2.0), g, t.text);
|
||||
if active {
|
||||
let x = r.left() + 6.0 + gw;
|
||||
p.line_segment([pos2(x, r.top() + 3.0), pos2(x, r.bottom() - 3.0)], Stroke::new(1.0, t.text));
|
||||
}
|
||||
if locked {
|
||||
padlock(p, pos2(r.right() - 7.0, r.center().y), t.accent);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small padlock, drawn in code.
|
||||
fn padlock(p: &egui::Painter, c: Pos2, color: Color32) {
|
||||
p.rect_filled(Rect::from_center_size(c + vec2(0.0, 1.5), vec2(7.0, 5.0)), 1.0, color);
|
||||
p.add(Shape::line(
|
||||
vec![pos2(c.x - 2.0, c.y - 1.0), pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 1.0)],
|
||||
Stroke::new(1.2, color),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::EntityKind;
|
||||
use egui::Event;
|
||||
|
||||
fn run_frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> Vec<Event> {
|
||||
let m = egui::Modifiers::NONE;
|
||||
vec![
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m },
|
||||
]
|
||||
}
|
||||
|
||||
fn line_end(app: &CadApp) -> Vec2 {
|
||||
app.session
|
||||
.doc()
|
||||
.ok()
|
||||
.and_then(|d| {
|
||||
d.model.iter().find_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some(l.b.xy()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Vec2::new(f64::NAN, f64::NAN))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_length_tab_and_enter_place_the_grip_and_keywords_pass_through() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
let r = app.session.execute("line", &serde_json::json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
run_frame(&mut app, &ctx, Vec::new());
|
||||
let at = app.canvas.rect.map_or(pos2(700.0, 450.0), |r| r.center() + vec2(40.0, -30.0));
|
||||
run_frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||||
app.session.start_grip_edit(&[(h, 2)]).unwrap();
|
||||
run_frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||||
assert_eq!(dims(&mut app).len(), 2, "length and length change");
|
||||
|
||||
// `20` Tab: the length locks and the next field is active; the grip sits on the circle.
|
||||
run_frame(&mut app, &ctx, vec![Event::Text("20".into())]);
|
||||
// (Tab reaches the command line as text: `cmdline::capture_tab`.)
|
||||
run_frame(&mut app, &ctx, vec![Event::Text("\t".into())]);
|
||||
assert_eq!(app.canvas.grip_dyn.locked, [(0, "20".to_owned())]);
|
||||
assert_eq!(app.canvas.grip_dyn.active, 1);
|
||||
assert!(app.cmd.buffer.is_empty());
|
||||
run_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(1.0, 0.0))]);
|
||||
let c = app.canvas.cursor.unwrap();
|
||||
assert!((c.len() - 20.0).abs() < 1e-9, "{c:?}");
|
||||
// Enter places it there.
|
||||
run_frame(&mut app, &ctx, key(egui::Key::Enter));
|
||||
assert!(app.session.running.is_none());
|
||||
let b = line_end(&app);
|
||||
assert!((b.len() - 20.0).abs() < 1e-9 && b.dist(c) < 1e-9, "{b:?} vs {c:?}");
|
||||
|
||||
// A keyword goes to the command as typed: `C` is Copy, not a value.
|
||||
app.session.start_grip_edit(&[(h, 2)]).unwrap();
|
||||
run_frame(&mut app, &ctx, vec![Event::Text("c".into())]);
|
||||
run_frame(&mut app, &ctx, key(egui::Key::Enter));
|
||||
assert!(app.session.running.is_some());
|
||||
app.session.input(Input::Point(Vec2::new(10.0, 10.0))).unwrap();
|
||||
let lines = app.session.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!(lines, 2, "Copy mode stretched a copy");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub mod docking;
|
||||
|
||||
pub mod dyninput;
|
||||
pub mod gpu;
|
||||
pub mod grip_dyn;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod layers;
|
||||
|
||||
Reference in new issue
Block a user