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:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:11:22 -04:00
1 parent 57f70b6b46
commit c390364a16
10 files changed
+788 -3

No files matched your search

+5 -1
View File
@@ -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,
}
}
}
}
+320
View File
@@ -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)));
}
}
+9
View File
@@ -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,
+10 -1
View File
@@ -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();
+6
View File
@@ -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)?,
+130
View File
@@ -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);
}
+20 -1
View File
@@ -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
+8
View File
@@ -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) {
+279
View File
@@ -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");
}
}
+1
View File
@@ -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;