PolarSnap, relative polar angles, Extension on ellipse and spline ends, tracking options in Drafting Settings (fixes #441) (#450)
Squash of PR #450 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a652c57537
commit
6f1ca71e98
|
|
@ -327,6 +327,11 @@ pub struct Settings {
|
|||
pub polar_flags: u32,
|
||||
/// POLARADDANG: additional polar tracking angles (radians), used with POLARMODE bit 4.
|
||||
pub polaraddang: Vec<f64>,
|
||||
/// SNAPTYPE 1: snap mode (F9) snaps along tracking paths at POLARDIST steps (PolarSnap)
|
||||
/// instead of to the grid.
|
||||
pub polarsnap: bool,
|
||||
/// POLARDIST: the PolarSnap step; 0 uses the snap X spacing.
|
||||
pub polardist: f64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -364,6 +369,8 @@ impl Default for Settings {
|
|||
qpmode: false,
|
||||
polar_flags: 0,
|
||||
polaraddang: Vec::new(),
|
||||
polarsnap: false,
|
||||
polardist: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -486,6 +493,9 @@ pub struct Session {
|
|||
pub last_command: Option<String>,
|
||||
/// LASTPOINT.
|
||||
pub last_point: Vec2,
|
||||
/// The last segment given at a point prompt (its base point and the point): polar angles
|
||||
/// relative to the last segment (POLARMODE bit 1) are measured from it.
|
||||
pub last_segment: Option<(Vec2, Vec2)>,
|
||||
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
|
||||
pub point_mods: Vec<pointmod::Frame>,
|
||||
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
|
||||
|
|
@ -537,6 +547,7 @@ impl Session {
|
|||
log: Vec::new(),
|
||||
last_command: None,
|
||||
last_point: Vec2::ZERO,
|
||||
last_segment: None,
|
||||
point_mods: Vec::new(),
|
||||
cursor: Vec2::ZERO,
|
||||
cursor_deferred: None,
|
||||
|
|
@ -923,12 +934,16 @@ impl Session {
|
|||
}
|
||||
|
||||
fn feed(&mut self, input: Option<Input>) -> Result<()> {
|
||||
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
|
||||
let Some(mut run) = self.running.take() else { return Ok(()) };
|
||||
let step = match input {
|
||||
Some(Input::Cancel) => Ok(Step::Cancel),
|
||||
Some(i) => {
|
||||
if let Input::Point(p) = &i {
|
||||
self.last_point = *p;
|
||||
if let Some(b) = base.filter(|b| b.is_finite() && p.is_finite() && b.dist(*p) > 1e-12) {
|
||||
self.last_segment = Some((b, *p));
|
||||
}
|
||||
// A temporary tracking point (TT) serves one point.
|
||||
self.tracking.release_temporary();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
//! ([`crate::Session::snap_cursor`]).
|
||||
|
||||
use cadcraft_doc::{Drawing, Prim, Space};
|
||||
use cadcraft_geom::{Line, PI, Segment, TAU, Vec2, angle_in_sweep, intersect_ext, norm_angle};
|
||||
use cadcraft_geom::{Ellipse, Line, PI, Segment, TAU, Vec2, angle_in_sweep, intersect_ext, norm_angle};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{SnapHit, mode};
|
||||
|
|
@ -31,7 +31,7 @@ const MAX_NEAR_PATHS: usize = 64;
|
|||
|
||||
/// POLARMODE bits.
|
||||
pub mod polarmode {
|
||||
/// Polar angles measured relative to the last segment (not modelled yet).
|
||||
/// Polar angles measured relative to the last segment, when the base point ends it.
|
||||
pub const RELATIVE: u32 = 1;
|
||||
/// Object snap tracking uses every polar angle, not only horizontal and vertical.
|
||||
pub const TRACK_POLAR: u32 = 2;
|
||||
|
|
@ -52,6 +52,9 @@ pub enum PathShape {
|
|||
/// The circle an arc lies on, without the arc itself (`gap`: its start and end angles, the
|
||||
/// arc running counterclockwise from one to the other) when it is the arc's extension.
|
||||
Circle { center: Vec2, radius: f64, gap: Option<(f64, f64)> },
|
||||
/// The ellipse an elliptical arc lies on (center, major-axis vector, ratio), without the arc
|
||||
/// itself (`gap`: its start and end parameters).
|
||||
Ellipse { center: Vec2, major: Vec2, ratio: f64, gap: Option<(f64, f64)> },
|
||||
}
|
||||
|
||||
impl PathShape {
|
||||
|
|
@ -70,6 +73,7 @@ impl PathShape {
|
|||
}
|
||||
center + u * radius
|
||||
}
|
||||
PathShape::Ellipse { center, major, ratio, .. } => ellipse_foot(&Ellipse::full(center, major, ratio), p),
|
||||
};
|
||||
(f.is_finite() && self.contains(f)).then_some(f)
|
||||
}
|
||||
|
|
@ -80,25 +84,62 @@ impl PathShape {
|
|||
PathShape::Ray { origin, dir } => (p - origin).dot(dir) >= -cadcraft_geom::EPS,
|
||||
PathShape::Line { .. } => true,
|
||||
PathShape::Circle { center, gap, .. } => gap.is_none_or(|(s, e)| !angle_in_sweep(center.angle_to(p), s, e)),
|
||||
PathShape::Ellipse { center, major, ratio, gap } => {
|
||||
gap.is_none_or(|(s, e)| !angle_in_sweep(Ellipse::full(center, major, ratio).param_of(p), s, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path's line or circle as a segment to intersect (`intersect_ext` extends it).
|
||||
fn segment(&self) -> Segment {
|
||||
/// The path's line or circle as segments to intersect (`intersect_ext` extends a line or
|
||||
/// circle; an ellipse is its tessellation, without the gap).
|
||||
fn segments(&self) -> Vec<Segment> {
|
||||
match *self {
|
||||
PathShape::Ray { origin, dir } | PathShape::Line { origin, dir } => Segment::Line(Line::new(origin, origin + dir)),
|
||||
PathShape::Circle { center, radius, .. } => Segment::Arc { arc: cadcraft_geom::Arc::new(center, radius, 0.0, PI), ccw: true },
|
||||
PathShape::Ray { origin, dir } | PathShape::Line { origin, dir } => vec![Segment::Line(Line::new(origin, origin + dir))],
|
||||
PathShape::Circle { center, radius, .. } => vec![Segment::Arc { arc: cadcraft_geom::Arc::new(center, radius, 0.0, PI), ccw: true }],
|
||||
PathShape::Ellipse { center, major, ratio, gap } => {
|
||||
let (start, end) = gap.map_or((0.0, TAU), |(s, e)| (e, s));
|
||||
let mut pts = Vec::new();
|
||||
Ellipse { center, major, ratio, start, end }.tessellate(major.len() * 1e-3, &mut pts);
|
||||
pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `x`, a crossing found with `seg` (one of [`PathShape::segments`]), lies on the
|
||||
/// path: on a tessellated ellipse it must be on that piece, not its extension.
|
||||
fn holds(&self, seg: &Segment, x: Vec2) -> bool {
|
||||
self.contains(x) && (!matches!(self, PathShape::Ellipse { .. }) || seg.closest(x).dist(x) <= 1e-9 * (1.0 + x.len()))
|
||||
}
|
||||
|
||||
fn is_finite(&self) -> bool {
|
||||
match *self {
|
||||
PathShape::Ray { origin, dir } | PathShape::Line { origin, dir } => origin.is_finite() && dir.is_finite() && dir != Vec2::ZERO,
|
||||
PathShape::Circle { center, radius, .. } => center.is_finite() && radius.is_finite() && radius > cadcraft_geom::EPS,
|
||||
PathShape::Ellipse { center, major, ratio, .. } => {
|
||||
center.is_finite() && major.is_finite() && major.len() > cadcraft_geom::EPS && ratio.is_finite() && ratio > 1e-9 && ratio <= 1e9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the ellipse `e` closest to `p`: the tessellated closest point, refined by a few
|
||||
/// Newton steps on the parameter.
|
||||
fn ellipse_foot(e: &Ellipse, p: Vec2) -> Vec2 {
|
||||
let mut t = e.param_of(e.closest(p));
|
||||
let minor = e.minor();
|
||||
for _ in 0..8 {
|
||||
let (d, d1) = (e.at_param(t) - p, minor * t.cos() - e.major * t.sin());
|
||||
let d2 = e.center - e.at_param(t);
|
||||
let (f, df) = (d.dot(d1), d1.dot(d1) + d.dot(d2));
|
||||
let next = t - f / df;
|
||||
if !next.is_finite() || df.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
t = next;
|
||||
}
|
||||
e.at_param(t)
|
||||
}
|
||||
|
||||
/// What sent a tracking path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -211,8 +252,19 @@ pub struct TrackConfig {
|
|||
impl TrackConfig {
|
||||
/// From the session settings and ANGBASE (radians), from which polar angles are measured.
|
||||
pub fn new(s: &crate::Settings, angbase: f64) -> Self {
|
||||
let polar = polar_angles(s.polarang, if s.polar_flags & polarmode::ADDITIONAL != 0 { &s.polaraddang } else { &[] }, angbase);
|
||||
let track_angles = if s.polar_flags & polarmode::TRACK_POLAR != 0 { polar.clone() } else { orthogonal() };
|
||||
Self::relative_to(s, angbase, None)
|
||||
}
|
||||
|
||||
/// As [`TrackConfig::new`]; with POLARMODE bit 1 polar angles are measured from `last`, the
|
||||
/// direction (radians) of the last segment when the base point ends it.
|
||||
pub fn relative_to(s: &crate::Settings, angbase: f64, last: Option<f64>) -> Self {
|
||||
let additional: &[f64] = if s.polar_flags & polarmode::ADDITIONAL != 0 { &s.polaraddang } else { &[] };
|
||||
let absolute = polar_angles(s.polarang, additional, angbase);
|
||||
let polar = match last.filter(|a| a.is_finite() && s.polar_flags & polarmode::RELATIVE != 0) {
|
||||
Some(a) => polar_angles(s.polarang, additional, a),
|
||||
None => absolute.clone(),
|
||||
};
|
||||
let track_angles = if s.polar_flags & polarmode::TRACK_POLAR != 0 { absolute } else { orthogonal() };
|
||||
TrackConfig {
|
||||
otrack: s.otrack,
|
||||
osmode: s.osmode,
|
||||
|
|
@ -276,7 +328,7 @@ impl Track {
|
|||
/// straight path the cursor slides on (`None` at crossings and on arc extensions).
|
||||
pub fn along(&self) -> Option<(Vec2, Vec2)> {
|
||||
let [p] = self.paths.as_slice() else { return None };
|
||||
if self.object.is_some() || matches!(p.shape, PathShape::Circle { .. }) {
|
||||
if self.object.is_some() || matches!(p.shape, PathShape::Circle { .. } | PathShape::Ellipse { .. }) {
|
||||
return None;
|
||||
}
|
||||
let dir = (self.point - p.from).normalized();
|
||||
|
|
@ -465,11 +517,15 @@ impl Tracker {
|
|||
if a.from.dist(b.from) <= cadcraft_geom::EPS {
|
||||
continue;
|
||||
}
|
||||
for x in intersect_ext(&a.shape.segment(), &b.shape.segment(), true) {
|
||||
let dd = x.dist(cursor);
|
||||
if dd <= aperture && a.shape.contains(x) && b.shape.contains(x) {
|
||||
let object = (a.kind == PathKind::Apparent && b.kind == PathKind::Apparent).then_some("Apparent Intersection");
|
||||
offer(dd, Track { point: x, paths: vec![*a, *b], object }, &mut best);
|
||||
for sa in a.shape.segments() {
|
||||
for sb in b.shape.segments() {
|
||||
for x in intersect_ext(&sa, &sb, true) {
|
||||
let dd = x.dist(cursor);
|
||||
if dd <= aperture && a.shape.holds(&sa, x) && b.shape.holds(&sb, x) {
|
||||
let object = (a.kind == PathKind::Apparent && b.kind == PathKind::Apparent).then_some("Apparent Intersection");
|
||||
offer(dd, Track { point: x, paths: vec![*a, *b], object }, &mut best);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -486,13 +542,14 @@ impl Tracker {
|
|||
for seg in super::prim_segments(prim) {
|
||||
let seg = super::fix_circle(&seg);
|
||||
for (p, _, _) in &near {
|
||||
for x in intersect_ext(&p.shape.segment(), &seg, true) {
|
||||
for (ps, x) in p.shape.segments().into_iter().flat_map(|ps| intersect_ext(&ps, &seg, true).into_iter().map(move |x| (ps, x)))
|
||||
{
|
||||
let on_object = seg.closest(x).dist(x) <= 1e-9 * (1.0 + x.len());
|
||||
// Off the object, or along an object's extension: apparent.
|
||||
let apparent = !on_object || p.kind == PathKind::Apparent;
|
||||
let dd = x.dist(cursor);
|
||||
if dd > aperture
|
||||
|| !p.shape.contains(x)
|
||||
|| !p.shape.holds(&ps, x)
|
||||
|| x.dist(p.from) <= tol
|
||||
|| !(on_object || extend)
|
||||
|| !cfg.running(if apparent { mode::APP } else { mode::INT })
|
||||
|
|
@ -580,28 +637,83 @@ pub fn candidate(
|
|||
None
|
||||
}
|
||||
|
||||
/// The line or arc endpoint nearest `p` within `aperture` and the extension path from it.
|
||||
/// The open ends of a primitive and the extension path from each: a line runs on straight, an
|
||||
/// arc or elliptical arc around its circle or ellipse, an open spline along its end tangent.
|
||||
fn extension_ends(prim: &Prim) -> Vec<(Vec2, PathShape)> {
|
||||
match prim {
|
||||
Prim::Seg(s) => [(s.start(), s.end()), (s.end(), s.start())]
|
||||
.into_iter()
|
||||
.map(|(end, other)| {
|
||||
let shape = match s {
|
||||
Segment::Line(_) => PathShape::Ray { origin: end, dir: (end - other).normalized() },
|
||||
Segment::Arc { arc, .. } => PathShape::Circle { center: arc.center, radius: arc.radius, gap: Some((arc.start, arc.end)) },
|
||||
};
|
||||
(end, shape)
|
||||
})
|
||||
.collect(),
|
||||
Prim::Ellipse(e) if !e.is_full() => {
|
||||
let shape = PathShape::Ellipse { center: e.center, major: e.major, ratio: e.ratio, gap: Some((e.start, e.end)) };
|
||||
vec![(e.at_param(e.start), shape), (e.at_param(e.end), shape)]
|
||||
}
|
||||
Prim::Spline(sp) if sp.is_valid() && !sp.closed => {
|
||||
let (t0, t1) = sp.domain();
|
||||
let h = (t1 - t0) * 1e-6;
|
||||
if !(h.is_finite() && h > 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let (a, b) = (sp.eval(t0), sp.eval(t1));
|
||||
if a.dist(b) <= cadcraft_geom::EPS * (1.0 + a.len()) {
|
||||
return Vec::new();
|
||||
}
|
||||
// The tangent at each end, pointing out of the curve: along the end leg of the control
|
||||
// polygon where the knots are clamped there, else from a point just inside.
|
||||
let n = sp.control.len();
|
||||
let clamped = |ks: &[f64]| ks.windows(2).all(|w| w.first() == w.get(1));
|
||||
let leg = |i: usize, j: usize, ks: Option<&[f64]>| {
|
||||
let (p, q) = (sp.control.get(i)?, sp.control.get(j)?);
|
||||
(ks.is_some_and(clamped) && p.dist(*q) > cadcraft_geom::EPS).then(|| (*p - *q).normalized())
|
||||
};
|
||||
let d = sp.degree;
|
||||
let start = leg(0, 1, sp.knots.get(..=d)).unwrap_or_else(|| (a - sp.eval(t0 + h)).normalized());
|
||||
let end = leg(n.saturating_sub(1), n.saturating_sub(2), sp.knots.get(n..)).unwrap_or_else(|| (b - sp.eval(t1 - h)).normalized());
|
||||
vec![(a, PathShape::Ray { origin: a, dir: start }), (b, PathShape::Ray { origin: b, dir: end })]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The open end nearest `p` within `aperture` and the extension path from it.
|
||||
fn extension_near(prims: &[Prim], p: Vec2, aperture: f64) -> Option<(Vec2, PathShape)> {
|
||||
let mut best: Option<(f64, Vec2, PathShape)> = None;
|
||||
for prim in prims {
|
||||
let Prim::Seg(s) = prim else { continue };
|
||||
for (end, other) in [(s.start(), s.end()), (s.end(), s.start())] {
|
||||
let dd = end.dist(p);
|
||||
if !dd.is_finite() || dd > aperture || best.as_ref().is_some_and(|(bd, ..)| *bd <= dd) {
|
||||
continue;
|
||||
}
|
||||
let shape = match s {
|
||||
Segment::Line(_) => PathShape::Ray { origin: end, dir: (end - other).normalized() },
|
||||
Segment::Arc { arc, .. } => PathShape::Circle { center: arc.center, radius: arc.radius, gap: Some((arc.start, arc.end)) },
|
||||
};
|
||||
if shape.is_finite() {
|
||||
best = Some((dd, end, shape));
|
||||
}
|
||||
for (end, shape) in prims.iter().flat_map(extension_ends) {
|
||||
let dd = end.dist(p);
|
||||
if !dd.is_finite() || dd > aperture || best.as_ref().is_some_and(|(bd, ..)| *bd <= dd) {
|
||||
continue;
|
||||
}
|
||||
if shape.is_finite() && end.is_finite() {
|
||||
best = Some((dd, end, shape));
|
||||
}
|
||||
}
|
||||
best.map(|(_, at, shape)| (at, shape))
|
||||
}
|
||||
|
||||
/// PolarSnap: a point sliding along one straight tracking path moves to the nearest whole
|
||||
/// multiple of `step` from the point the path comes from (at least one step along a ray).
|
||||
pub fn polar_snap(t: &Track, step: f64) -> Option<Track> {
|
||||
let [path] = t.paths.as_slice() else { return None };
|
||||
if t.object.is_some() || !path.slides() || !(step.is_finite() && step > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let (dir, min) = match path.shape {
|
||||
PathShape::Ray { dir, .. } => (dir, 1.0),
|
||||
PathShape::Line { dir, .. } => (dir, f64::NEG_INFINITY),
|
||||
PathShape::Circle { .. } | PathShape::Ellipse { .. } => return None,
|
||||
};
|
||||
let k = ((t.point - path.from).dot(dir) / step).round().max(min);
|
||||
let point = path.from + dir * (k * step);
|
||||
(k.is_finite() && point.is_finite()).then(|| Track { point, ..t.clone() })
|
||||
}
|
||||
|
||||
/// What one cursor update gives: the point to use and why.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -644,7 +756,9 @@ impl crate::Session {
|
|||
if !raw.is_finite() {
|
||||
return CursorSnap { point: raw, ..Default::default() };
|
||||
}
|
||||
let cfg = TrackConfig::new(&s, angbase);
|
||||
// Polar angles relative to the last segment when the base point is where it ends.
|
||||
let last = self.last_segment.filter(|(_, b)| q.base.is_some_and(|base| base.near(*b, 1e-9 * (1.0 + b.len())))).map(|(a, b)| a.angle_to(b));
|
||||
let cfg = TrackConfig::relative_to(&s, angbase, last);
|
||||
let osmode = s.osmode | if s.osnaphatch { mode::HATCH } else { 0 };
|
||||
let hit = super::osnap(&doc, &space, raw, q.aperture, osmode, q.base, q.deferred);
|
||||
let cand = candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base);
|
||||
|
|
@ -654,11 +768,20 @@ impl crate::Session {
|
|||
return CursorSnap { point: h.point, snap: Some(h), track: None, wait };
|
||||
}
|
||||
let mut p = raw;
|
||||
if s.snapmode {
|
||||
if s.snapmode && !s.polarsnap {
|
||||
let (origin, angle) = super::grid_frame(&doc);
|
||||
p = super::grid_snap(p, s.snapunit, origin, angle);
|
||||
}
|
||||
if let Some(t) = self.tracking.track(&doc, &space, p, q.aperture, q.tol, &cfg, q.base) {
|
||||
if let Some(mut t) = self.tracking.track(&doc, &space, p, q.aperture, q.tol, &cfg, q.base) {
|
||||
// PolarSnap (SNAPTYPE 1): steps of POLARDIST, or of the snap X spacing when it is 0.
|
||||
let step = if s.polardist > 0.0 { s.polardist } else { s.snapunit.x };
|
||||
if s.snapmode
|
||||
&& s.polarsnap
|
||||
&& let Some(snapped) = polar_snap(&t, step)
|
||||
{
|
||||
t = snapped;
|
||||
self.tracking.last = Some(t.clone());
|
||||
}
|
||||
return CursorSnap { point: t.point, snap: None, track: Some(t), wait };
|
||||
}
|
||||
if let Some(b) = q.base
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const SESSION_VARS: &[&str] = &[
|
|||
"POLARMODE",
|
||||
"POLARANG",
|
||||
"POLARADDANG",
|
||||
"POLARDIST",
|
||||
"SNAPTYPE",
|
||||
"AUTOSNAP",
|
||||
"GRIDMODE",
|
||||
"SNAPMODE",
|
||||
|
|
@ -47,7 +49,9 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
|||
"OSNAPHATCH" => json!(i32::from(st.osnaphatch)),
|
||||
"ORTHOMODE" => json!(i32::from(st.orthomode)),
|
||||
"POLARMODE" => json!(st.polar_flags),
|
||||
"POLARADDANG" => json!(st.polaraddang.iter().map(|a| format!("{}", (a.to_degrees() * 1e6).round() / 1e6)).collect::<Vec<_>>().join(";")),
|
||||
"POLARADDANG" => json!(format_angle_list(&st.polaraddang)),
|
||||
"POLARDIST" => json!(st.polardist),
|
||||
"SNAPTYPE" => json!(i32::from(st.polarsnap)),
|
||||
// Marker, tooltip and tracking tooltip always on (1 + 4 + 32); polar 8, tracking 16.
|
||||
"AUTOSNAP" => json!(37 + 8 * u32::from(st.polarmode) + 16 * u32::from(st.otrack)),
|
||||
"POLARANG" => json!(st.polarang.to_degrees()),
|
||||
|
|
@ -140,6 +144,19 @@ pub fn is_read_only(name: &str) -> bool {
|
|||
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// POLARADDANG as typed: up to ten angles in degrees separated by `;` (radians out); `None` when
|
||||
/// any is not a number or there are too many.
|
||||
pub fn parse_angle_list(text: &str) -> Option<Vec<f64>> {
|
||||
let angles: Option<Vec<f64>> =
|
||||
text.split(';').map(str::trim).filter(|t| !t.is_empty()).map(|t| t.parse::<f64>().ok().filter(|a| a.is_finite())).collect();
|
||||
Some(angles.filter(|a| a.len() <= 10)?.into_iter().map(f64::to_radians).collect())
|
||||
}
|
||||
|
||||
/// POLARADDANG as shown: degrees separated by `;`.
|
||||
pub fn format_angle_list(angles: &[f64]) -> String {
|
||||
angles.iter().map(|a| format!("{}", (a.to_degrees() * 1e6).round() / 1e6)).collect::<Vec<_>>().join(";")
|
||||
}
|
||||
|
||||
pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let n = name.trim().to_ascii_uppercase();
|
||||
if is_read_only(&n) {
|
||||
|
|
@ -154,11 +171,10 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
|||
"POLARMODE" => st.polar_flags = as_i(v).filter(|b| (0..=15).contains(b)).ok_or_else(bad)? as u32,
|
||||
"POLARADDANG" => {
|
||||
let text = v.as_str().map(str::to_string).or_else(|| as_f64(v).map(|f| f.to_string())).ok_or_else(bad)?;
|
||||
let angles: Option<Vec<f64>> =
|
||||
text.split(';').map(str::trim).filter(|t| !t.is_empty()).map(|t| t.parse::<f64>().ok().filter(|a| a.is_finite())).collect();
|
||||
let angles = angles.filter(|a| a.len() <= 10).ok_or_else(bad)?;
|
||||
st.polaraddang = angles.into_iter().map(f64::to_radians).collect();
|
||||
st.polaraddang = parse_angle_list(&text).ok_or_else(bad)?;
|
||||
}
|
||||
"POLARDIST" => st.polardist = as_f64(v).filter(|d| *d >= 0.0 && *d < 1e12).ok_or_else(bad)?,
|
||||
"SNAPTYPE" => st.polarsnap = as_i(v).filter(|b| (0..=1).contains(b)).ok_or_else(bad)? == 1,
|
||||
"AUTOSNAP" => {
|
||||
let bits = as_i(v).filter(|b| (0..=63).contains(b)).ok_or_else(bad)?;
|
||||
st.polarmode = bits & 8 != 0;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Temporary tracking (#441): the `TT` temporary tracking point and the `TK` tracking modifier,
|
||||
//! typed at any point prompt and driven without the UI.
|
||||
//! Temporary tracking and polar options (#441): the `TT` temporary tracking point and the `TK`
|
||||
//! tracking modifier typed at any point prompt, PolarSnap, polar angles relative to the last
|
||||
//! segment and Extension on elliptical arcs and splines, all driven without the UI.
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::snap::tracking::{Acquired, CursorQuery, PathKind};
|
||||
use cadcraft_engine::snap;
|
||||
use cadcraft_engine::snap::tracking::{Acquired, CursorQuery, DWELL, PathKind, PathShape};
|
||||
use serde_json::json;
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
|
|
@ -120,3 +122,102 @@ fn enter_at_the_first_tracking_point_and_hostile_input_are_harmless() {
|
|||
assert!(s.running.is_none());
|
||||
assert!(s.tracking.acquired.len() <= cadcraft_engine::snap::tracking::MAX_ACQUIRED);
|
||||
}
|
||||
|
||||
// PolarSnap, polar angles relative to the last segment, Extension on ellipse and spline ends.
|
||||
|
||||
fn setvar(s: &mut Session, name: &str, value: serde_json::Value) {
|
||||
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
|
||||
}
|
||||
|
||||
/// Rest the cursor on `p` long enough to acquire what is there, then move off.
|
||||
fn rest(s: &mut Session, p: Vec2) {
|
||||
for (at, now) in [(p, 0.0), (p, DWELL + 0.01), (v(1e5, 1e5), DWELL + 0.02)] {
|
||||
s.snap_cursor(at, &CursorQuery { base: None, deferred: false, aperture: 0.5, tol: 0.1, now, shift: false });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polarsnap_steps_along_polar_paths_by_polardist() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "OSMODE", json!(0));
|
||||
setvar(&mut s, "SNAPMODE", json!(1));
|
||||
setvar(&mut s, "SNAPTYPE", json!(1));
|
||||
setvar(&mut s, "POLARDIST", json!(2.5));
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "SNAPTYPE" })).unwrap()["value"], json!(1));
|
||||
s.cmdline("line 0,0").unwrap();
|
||||
let base = Some(v(0.0, 0.0));
|
||||
assert!(near(hover(&mut s, v(6.1, 0.03), base), 5.0, 0.0));
|
||||
assert!(near(hover(&mut s, v(6.4, -0.04), base), 7.5, 0.0));
|
||||
assert!(near(hover(&mut s, v(0.02, -8.9), base), 0.0, -10.0));
|
||||
// A distance typed there still runs along the path.
|
||||
s.cmdline("3").unwrap();
|
||||
assert!(near(s.last_point, 0.0, -3.0), "{:?}", s.last_point);
|
||||
// Off every path the cursor is free: PolarSnap replaces the grid snap.
|
||||
assert!(near(hover(&mut s, v(3.3, 2.2), Some(v(0.0, -3.0))), 3.3, 2.2));
|
||||
// POLARDIST 0: steps of the snap X spacing.
|
||||
setvar(&mut s, "POLARDIST", json!(0));
|
||||
setvar(&mut s, "SNAPUNIT", json!([0.4, 0.4]));
|
||||
assert!(near(hover(&mut s, v(2.9, -3.03), Some(v(0.0, -3.0))), 2.8, -3.0));
|
||||
// Grid snap again with SNAPTYPE 0.
|
||||
setvar(&mut s, "SNAPTYPE", json!(0));
|
||||
assert!(near(hover(&mut s, v(3.3, 2.1), Some(v(0.0, -3.0))), 3.2, 2.0));
|
||||
for bad in [json!(2), json!(-1), json!("x")] {
|
||||
assert!(s.execute("setvar", &json!({ "name": "SNAPTYPE", "value": bad })).is_err());
|
||||
}
|
||||
assert!(s.execute("setvar", &json!({ "name": "POLARDIST", "value": -1 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_angles_relative_to_the_last_segment() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "OSMODE", json!(0));
|
||||
s.cmdline("line 0,0 10,10").unwrap();
|
||||
let base = Some(v(10.0, 10.0));
|
||||
// Absolute: 45° is no polar angle with the 90° increment.
|
||||
hover(&mut s, v(15.0, 15.05), base);
|
||||
assert!(s.tracking.last.is_none());
|
||||
setvar(&mut s, "POLARMODE", json!(1));
|
||||
// Relative: straight on and at right angles to the 45° segment.
|
||||
let p = hover(&mut s, v(15.0, 15.05), base);
|
||||
assert!(near(p, 15.025, 15.025), "{p:?}");
|
||||
assert_eq!(s.tracking.last.as_ref().unwrap().paths[0].kind, PathKind::Polar);
|
||||
let p = hover(&mut s, v(7.0, 13.04), base);
|
||||
assert!(near(p, 6.98, 13.02), "{p:?}");
|
||||
// Elsewhere than the end of the last segment the angles are absolute again.
|
||||
let p = hover(&mut s, v(25.0, 0.03), Some(v(20.0, 0.0)));
|
||||
assert!(near(p, 25.0, 0.0), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_runs_around_an_elliptical_arc_and_off_a_spline_end() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "OSMODE", json!(snap::mode::END | snap::mode::EXT));
|
||||
// A quarter ellipse from 10,0 to 0,5.
|
||||
s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap();
|
||||
rest(&mut s, v(0.02, 5.02));
|
||||
let a = 135f64.to_radians();
|
||||
let p = hover(&mut s, v(10.0 * a.cos(), 5.0 * a.sin() + 0.05), None);
|
||||
let t = s.tracking.last.clone().expect("on the ellipse's extension");
|
||||
assert_eq!(t.paths[0].kind, PathKind::Extension);
|
||||
assert!(matches!(t.paths[0].shape, PathShape::Ellipse { .. }));
|
||||
assert!(((p.x / 10.0).powi(2) + (p.y / 5.0).powi(2) - 1.0).abs() < 1e-3, "{p:?}");
|
||||
// Not along the arc itself.
|
||||
let a = 45f64.to_radians();
|
||||
hover(&mut s, v(10.0 * a.cos(), 5.0 * a.sin() + 0.05), None);
|
||||
assert!(s.tracking.last.is_none());
|
||||
|
||||
// An open spline runs on along its end tangent (here 45° down from 15,0).
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "OSMODE", json!(snap::mode::END | snap::mode::EXT));
|
||||
s.execute("spline", &json!({ "control": [[0, 0], [5, 5], [10, 5], [15, 0]], "degree": 3 })).unwrap();
|
||||
rest(&mut s, v(15.02, 0.02));
|
||||
let p = hover(&mut s, v(18.0, -2.96), None);
|
||||
let t = s.tracking.last.clone().expect("on the spline's extension");
|
||||
assert_eq!((t.paths[0].kind, t.paths[0].from), (PathKind::Extension, v(15.0, 0.0)));
|
||||
assert!(near(p, 17.98, -2.98), "{p:?}");
|
||||
// Hostile cursors near the acquired end.
|
||||
for x in [f64::NAN, f64::INFINITY, -1e308, 1e-300] {
|
||||
let r = s.snap_cursor(v(15.0 + x, x), &CursorQuery { base: Some(v(x, 0.0)), deferred: false, aperture: 0.5, tol: 0.1, now: x, shift: false });
|
||||
assert!(r.track.is_none_or(|t| t.point.is_finite()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -475,6 +475,11 @@ fn draw_tracking(p: &egui::Painter, xf: &Xf, acquired: &[snap::tracking::Acquire
|
|||
let (a0, span) = (center.angle_to(tr.point), (far / radius).min(std::f64::consts::PI));
|
||||
(0..=96).map(|i| xf.to_screen(Vec2::polar(center, radius, a0 - span + span * f64::from(i) / 48.0))).collect()
|
||||
}
|
||||
PathShape::Ellipse { center, major, ratio, .. } => {
|
||||
let e = cadcraft_geom::Ellipse::full(center, major, ratio);
|
||||
let (t0, span) = (e.param_of(tr.point), (far / (major.len() * ratio.min(1.0))).min(std::f64::consts::PI));
|
||||
(0..=96).map(|i| xf.to_screen(e.at_param(t0 - span + span * f64::from(i) / 48.0))).collect()
|
||||
}
|
||||
};
|
||||
p.extend(Shape::dotted_line(&pts, t.snap, 6.0, 1.0));
|
||||
let (distance, angle) = (
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||
ui.label(crate::tl!("Y"));
|
||||
ui.add(egui::DragValue::new(&mut s.snapunit.y).speed(0.05).range(0.0001..=1e6));
|
||||
});
|
||||
// SNAPTYPE and POLARDIST.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Snap type"));
|
||||
ui.radio_value(&mut s.polarsnap, false, crate::tl!("Grid snap"));
|
||||
ui.radio_value(&mut s.polarsnap, true, crate::tl!("PolarSnap"));
|
||||
});
|
||||
ui.add_enabled_ui(s.polarsnap, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Polar distance"));
|
||||
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6));
|
||||
});
|
||||
});
|
||||
ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Grid spacing"));
|
||||
|
|
@ -63,6 +75,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||
});
|
||||
});
|
||||
s.polarang = deg.to_radians();
|
||||
tracking_options(ui, s);
|
||||
ui.separator();
|
||||
ui.heading(crate::tl!("Object Snap"));
|
||||
let mut on = s.osmode & cadcraft_engine::snap::mode::OFF == 0;
|
||||
|
|
@ -108,6 +121,50 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The polar and object snap tracking options of Drafting Settings: additional angles
|
||||
/// (POLARMODE bit 4, POLARADDANG), tracking on every polar angle (bit 2) and angles relative to
|
||||
/// the last segment (bit 1).
|
||||
fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) {
|
||||
use cadcraft_engine::snap::tracking::polarmode;
|
||||
/// A radio button for POLARMODE `bit` being `on`.
|
||||
fn flag(ui: &mut egui::Ui, flags: &mut u32, bit: u32, on: bool, label: &str) {
|
||||
if ui.radio((*flags & bit != 0) == on, label).clicked() {
|
||||
*flags = if on { *flags | bit } else { *flags & !bit };
|
||||
}
|
||||
}
|
||||
let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() {
|
||||
s.polar_flags ^= polarmode::ADDITIONAL;
|
||||
}
|
||||
// The list as typed (degrees separated by `;`), kept while it doesn't parse yet.
|
||||
let id = ui.id().with("polaraddang");
|
||||
let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang);
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
|
||||
let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0));
|
||||
if resp.changed()
|
||||
&& let Some(angles) = cadcraft_engine::sysvars::parse_angle_list(&text)
|
||||
{
|
||||
s.polaraddang = angles;
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(id, text));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
});
|
||||
ui.label(crate::tl!("Object Snap Tracking Settings"));
|
||||
ui.horizontal(|ui| {
|
||||
flag(ui, &mut s.polar_flags, polarmode::TRACK_POLAR, false, crate::tl!("Track orthogonally only"));
|
||||
flag(ui, &mut s.polar_flags, polarmode::TRACK_POLAR, true, crate::tl!("Track using all polar angle settings"));
|
||||
});
|
||||
ui.label(crate::tl!("Polar angle measurement"));
|
||||
ui.horizontal(|ui| {
|
||||
flag(ui, &mut s.polar_flags, polarmode::RELATIVE, false, crate::tl!("Absolute"));
|
||||
flag(ui, &mut s.polar_flags, polarmode::RELATIVE, true, crate::tl!("Relative to last segment"));
|
||||
});
|
||||
}
|
||||
|
||||
/// LUNITS values in the order the Type list shows them.
|
||||
const LENGTH_TYPES: [(i64, &str); 5] = [(4, "Architectural"), (2, "Decimal"), (3, "Engineering"), (5, "Fractional"), (1, "Scientific")];
|
||||
/// AUNITS values.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
About CADCraft Про CADCraft
|
||||
Absolute Абсолютні
|
||||
Add Leader Додати виноску
|
||||
Additional angles Додаткові кути
|
||||
Align Вирівняти
|
||||
Align Text Вирівняти текст
|
||||
Aligned Паралельний
|
||||
|
|
@ -312,6 +313,7 @@
|
|||
Grid (F7) Сітка (F7)
|
||||
Grid Display Відображення сітки
|
||||
Grid On (F7) Увімкнути сітку (F7)
|
||||
Grid snap Прив'язка до сітки
|
||||
Grid spacing Крок сітки
|
||||
Grip Edit Редагування ручками
|
||||
Grip Mirror Дзеркальне відображення ручками
|
||||
|
|
@ -494,6 +496,7 @@
|
|||
Object Snap On (F3) Увімкнути об'єктну прив'язку (F3)
|
||||
Object Snap Tracking Відстеження об'єктної прив'язки
|
||||
Object Snap Tracking (F11) Відстеження об'єктної прив'язки (F11)
|
||||
Object Snap Tracking Settings Параметри відстеження об'єктної прив'язки
|
||||
Object type: Тип об'єкта:
|
||||
Off Вимкнено
|
||||
Offset Зміщення
|
||||
|
|
@ -575,7 +578,10 @@
|
|||
Polar Tracking Полярне відстеження
|
||||
Polar Tracking (F10) Полярне відстеження (F10)
|
||||
Polar Tracking On (F10) Увімкнути полярне відстеження (F10)
|
||||
Polar angle measurement Вимірювання полярного кута
|
||||
Polar distance Полярна відстань
|
||||
Polar: {distance} < {angle}° Полярне: {distance} < {angle}°
|
||||
PolarSnap Полярна прив'язка
|
||||
Polygon Правильний багатокутник
|
||||
Polygonal Багатокутний
|
||||
Polyline Полілінія
|
||||
|
|
@ -624,6 +630,7 @@
|
|||
Region Область
|
||||
Region/Mass Properties Властивості області/маси
|
||||
Relative Відносні
|
||||
Relative to last segment Відносно останнього сегмента
|
||||
Remove Leader Видалити виноску
|
||||
Remove the viewport override Видалити перевизначення видового екрана
|
||||
Rename Dimension Style Перейменувати розмірний стиль
|
||||
|
|
@ -752,6 +759,7 @@
|
|||
Snap On (F9) Увімкнути крокову прив'язку (F9)
|
||||
Snap X spacing Крок прив'язки X
|
||||
Snap and Grid Крокова прив'язка та сітка
|
||||
Snap type Тип крокової прив'язки
|
||||
Solid Тіло
|
||||
Sort Сортування
|
||||
Specify a point Укажіть точку
|
||||
|
|
@ -935,6 +943,8 @@
|
|||
Tools Інструменти
|
||||
Torus Тор
|
||||
Trace Смуга
|
||||
Track orthogonally only Відстежувати лише ортогонально
|
||||
Track using all polar angle settings Відстежувати за всіма полярними кутами
|
||||
Transparency Прозорість
|
||||
Trim Обрізати
|
||||
True Color Колір RGB
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
|
|
@ -60,14 +60,13 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
|
|||
|
||||
| # | Gap | User impact | Est. |
|
||||
|---|---|---|---|
|
||||
| U1 | Tracking leftovers: polar angles relative to the last segment (POLARMODE 1), PolarSnap, Extension from ellipse and spline ends, the tracking options in Drafting Settings (object snap tracking, Extension, Parallel and Apparent Intersection landed in #379; the temporary tracking point TT in #441) | Medium | 2–4 |
|
||||
| U2 | Dynamic input: grip dimensional input, lock icons, DYNPROMPT options menu (pointer boxes with Tab and relative entry landed in #60) | Medium | 3–5 |
|
||||
| U4 | Tab snap cycling offers the closest snap of each mode, not every candidate. Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393), the menu's Temporary track point in #441 | Low | 1 |
|
||||
| U5 | Hot-grip options (Base point, Copy, Undo, Reference) are shown but ignored; multiple hot grips; multifunctional grip menus (Add/Remove Vertex, Convert to Arc) | Medium-high | 6–10 |
|
||||
| U6 | Selection: lasso; selection cycling (a list of overlapping objects, SELECTIONCYCLING); the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, and ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) landed | Medium | 3–5 |
|
||||
| U7 | Right-click context menus (canvas, selection, command, command line, palettes, tabs) | Medium-high | 6–10 |
|
||||
| U8 | Snaps missing on some objects: MID on circles/ellipses/splines; NOD on dimension points; INS on ATTDEF/MLEADER; TAN on ellipses/splines; GCEN on splines | Medium | 4–6 |
|
||||
| U9 | Polar: additional angles, relative to last segment, PolarSnap; isometric snap/grid and ISODRAFT (dead toggle) | Medium | 4–6 |
|
||||
| U9 | Isometric snap/grid and ISODRAFT (dead toggle). Polar additional angles, angles relative to the last segment and PolarSnap landed (#441) | Medium | 2–4 |
|
||||
| U10 | UNDO Mark/Back/BEgin/End/Auto; SETVAR coverage (~60 of ~1,000 variables) | Medium | 6–10 |
|
||||
| U11 | Zoom Realtime and Dynamic, ZOOMFACTOR; named views (VIEW); tiled model viewports | Medium | 6–8 |
|
||||
| U12 | Workspaces, navigation bar, CUI customisation, alias editor, ViewCube drag (#7) | Low-medium | 15–25 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue