From c4cc40355aa4d93ef89ffb332bd292175fc16902 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:13:02 +0200 Subject: [PATCH] feat(drafting): isometric snap/grid, ISODRAFT, ISOPLANE, ELLIPSE Isocircle (#561) Squash of PR #561 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 25 ++- crates/engine/src/cmd/settings.rs | 16 +- crates/engine/src/cmd/settings/iso.rs | 194 +++++++++++++++++ crates/engine/src/lib.rs | 6 +- crates/engine/src/snap.rs | 1 + crates/engine/src/snap/iso.rs | 291 ++++++++++++++++++++++++++ crates/engine/src/snap/tracking.rs | 4 +- crates/engine/src/sysvars.rs | 6 + crates/engine/tests/isometric.rs | 252 ++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 43 +++- crates/ui-egui/src/dialogs.rs | 5 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/menus.rs | 9 + docs/ui-parity.md | 13 +- 14 files changed, 849 insertions(+), 19 deletions(-) create mode 100644 crates/engine/src/cmd/settings/iso.rs create mode 100644 crates/engine/src/snap/iso.rs create mode 100644 crates/engine/tests/isometric.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 6849a63..b0c5140 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { CommandSpec::new("ellipse", "Ellipse", run_ellipse) .menu(&["Draw", "Ellipse", "Center"]) .alias(&["el"]) - .params("{center, major: [dx,dy], ratio, start?, end? (degrees)}") + .params("{center, major: [dx,dy], ratio, start?, end? (degrees)} | {isocircle: {center, radius | diameter, plane?}}") .interactive(|_| Ok(Box::new(EllipseM::default()))), CommandSpec::new("point", "Point", run_point) .menu(&["Draw", "Point", "Single Point"]) @@ -225,6 +225,9 @@ fn run_polygon(s: &mut Session, p: &Value) -> Result { } fn run_ellipse(s: &mut Session, p: &Value) -> Result { + if p.get("isocircle").is_some() { + return super::settings::iso::run_isocircle(s, p); + } let c = point_req("ellipse", p, "center")?; let m = point_req("ellipse", p, "major")?; let ratio = f64_req("ellipse", p, "ratio")?; @@ -955,6 +958,8 @@ struct EllipseM { /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. param: bool, start: Option, + /// The Isocircle option (isometric snap style) in progress. + iso: Option, } impl EllipseM { @@ -1005,7 +1010,7 @@ impl Interactive for EllipseM { fn name(&self) -> &'static str { "ELLIPSE" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if let Some(e) = &self.ellipse { let msg = match (self.start.is_none(), self.param) { (true, false) => "Specify start angle", @@ -1015,7 +1020,11 @@ impl Interactive for EllipseM { }; return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); } + if let Some(iso) = &self.iso { + return iso.prompt(); + } match (self.center_mode, self.pts.len()) { + (false, 0) if s.settings.isodraft => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center", "Isocircle"]), (false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]), (false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), (true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT), @@ -1067,7 +1076,14 @@ impl Interactive for EllipseM { } } } + if let Some(iso) = &mut self.iso { + return match iso.input(s, i)? { + Some(e) => self.place(s, e), + None => Ok(Step::Continue), + }; + } match i { + Input::Keyword(k) if k == "Isocircle" && self.pts.is_empty() => self.iso = Some(Default::default()), Input::Keyword(k) if k == "Center" => self.center_mode = true, Input::Keyword(k) if k == "Arc" => self.arc_mode = true, Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true, @@ -1103,7 +1119,10 @@ impl Interactive for EllipseM { } Ok(Step::Continue) } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(iso) = &self.iso { + return iso.preview(s, c).map(|e| super::settings::iso::ellipse_entity(&e)).into_iter().collect(); + } if self.pts.len() < 2 { return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default(); } diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index b5b658a..d90c6b9 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +pub(super) mod iso; + fn toggle(s: &mut Session, p: &Value, get: fn(&mut crate::Settings) -> &mut bool, name: &str) -> Result { let v = get(&mut s.settings); *v = p.get("on").and_then(Value::as_bool).unwrap_or(!*v); @@ -56,11 +58,19 @@ pub fn specs() -> Vec { .enabled(always) .noundo() .transparent(), - CommandSpec::new("isodraft", "Isometric Drafting", |s, p| toggle(s, p, |st| &mut st.isodraft, "Isodraft")) - .params("{on?: bool}") + CommandSpec::new("isodraft", "Isometric Drafting", iso::run_isodraft) + .params("{on?: bool, plane?: \"left\"|\"top\"|\"right\"|\"orthographic\"} (neither: toggle)") .enabled(always) .noundo() - .transparent(), + .transparent() + .interactive(|_| Ok(Box::new(iso::IsodraftM))), + CommandSpec::new("isoplane", "Isometric Plane", iso::run_isoplane) + .key("F5") + .params("{plane?: \"left\"|\"top\"|\"right\"} (none: the next plane)") + .enabled(always) + .noundo() + .transparent() + .interactive(|_| Ok(Box::new(iso::IsoplaneM))), CommandSpec::new("transparencydisplay", "Show/Hide Transparency", |s, p| toggle(s, p, |st| &mut st.transparency_display, "Transparency")) .params("{on?: bool}") .enabled(always) diff --git a/crates/engine/src/cmd/settings/iso.rs b/crates/engine/src/cmd/settings/iso.rs new file mode 100644 index 0000000..810d2b5 --- /dev/null +++ b/crates/engine/src/cmd/settings/iso.rs @@ -0,0 +1,194 @@ +//! Isometric drafting commands: ISODRAFT (isometric snap style on with a plane, or back to +//! orthographic), ISOPLANE (the current plane; F5 cycles it) and ELLIPSE's Isocircle option. + +use cadcraft_geom::{Ellipse, Vec2}; +use serde_json::{Value, json}; + +use super::super::{bad, f64_req, point_req, str_param}; +use crate::snap::iso::{self, IsoPlane}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +fn report(s: &Session) -> Value { + let st = &s.settings; + json!({ + "on": st.isodraft, + "plane": st.isoplane.name(), + "snapstyl": i32::from(st.isodraft), + "snapisopair": st.isoplane.index(), + "message": if st.isodraft { format!("", st.isoplane.name()) } else { "".to_string() }, + }) +} + +/// The `plane` parameter: a plane, `orthographic` (`None` inside), or absent. +fn plane_param(cmd: &str, p: &Value) -> Result>> { + let Some(t) = str_param(p, "plane") else { return Ok(None) }; + if !t.trim().is_empty() && "orthographic".starts_with(&t.trim().to_ascii_lowercase()) { + return Ok(Some(None)); + } + IsoPlane::parse(t).map(|pl| Some(Some(pl))).ok_or_else(|| bad(cmd, "`plane` is left, top, right or orthographic")) +} + +/// ISODRAFT `{on?: bool, plane?}`: a plane turns isometric drafting on in that plane, +/// `orthographic` turns it off; `on` sets it; neither toggles it (the status-bar button). +pub(super) fn run_isodraft(s: &mut Session, p: &Value) -> Result { + match plane_param("isodraft", p)? { + Some(Some(pl)) => { + s.settings.isodraft = true; + s.settings.isoplane = pl; + } + Some(None) => s.settings.isodraft = false, + None => s.settings.isodraft = p.get("on").and_then(Value::as_bool).unwrap_or(!s.settings.isodraft), + } + s.touch(); + Ok(report(s)) +} + +/// ISOPLANE `{plane?}`: set the current isometric plane, or switch to the next one (F5). +pub(super) fn run_isoplane(s: &mut Session, p: &Value) -> Result { + let pl = match plane_param("isoplane", p)? { + Some(Some(pl)) => pl, + Some(None) => return Err(bad("isoplane", "`plane` is left, top or right")), + None => s.settings.isoplane.next(), + }; + s.settings.isoplane = pl; + s.touch(); + let mut v = report(s); + if let Some(m) = v.get_mut("message") { + *m = json!(format!("", pl.name())); + } + Ok(v) +} + +/// ISODRAFT at the command line: Orthographic or a plane; Enter toggles. +#[derive(Default)] +pub(super) struct IsodraftM; + +impl Interactive for IsodraftM { + fn name(&self) -> &'static str { + "ISODRAFT" + } + fn prompt(&self, s: &Session) -> Prompt { + let st = &s.settings; + let default = if st.isodraft { "Orthographic" } else { st.isoplane.name() }; + Prompt::new("Drafting mode?", Accept { enter: true, ..Accept::default() }).kw(&["Orthographic", "Left", "Top", "Right"]).default(default) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let p = match i { + Input::Keyword(k) | Input::Text(k) => json!({ "plane": k }), + Input::Enter => json!({}), + Input::Cancel => return Ok(Step::Cancel), + _ => return Ok(Step::Continue), + }; + let v = run_isodraft(s, &p)?; + if let Some(m) = v.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + +/// ISOPLANE at the command line: a plane; Enter switches to the next one. +#[derive(Default)] +pub(super) struct IsoplaneM; + +impl Interactive for IsoplaneM { + fn name(&self) -> &'static str { + "ISOPLANE" + } + fn prompt(&self, s: &Session) -> Prompt { + Prompt::new("Isometric plane?", Accept { enter: true, ..Accept::default() }) + .kw(&["Left", "Top", "Right"]) + .default(s.settings.isoplane.next().name()) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let p = match i { + Input::Keyword(k) | Input::Text(k) => json!({ "plane": k }), + Input::Enter => json!({}), + Input::Cancel => return Ok(Step::Cancel), + _ => return Ok(Step::Continue), + }; + let v = run_isoplane(s, &p)?; + if let Some(m) = v.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + +/// ELLIPSE `{isocircle: {center, radius | diameter, plane?}}`: an isometric circle in `plane` +/// (default: the current isometric plane). +pub(crate) fn run_isocircle(s: &mut Session, p: &Value) -> Result { + let c = p.get("isocircle").ok_or_else(|| bad("ellipse", "`isocircle` is required"))?; + let center = point_req("ellipse", c, "center")?; + let r = match c.get("diameter") { + Some(_) => f64_req("ellipse", c, "diameter")? / 2.0, + None => f64_req("ellipse", c, "radius")?, + }; + let plane = match plane_param("ellipse", c)? { + Some(Some(pl)) => pl, + Some(None) => return Err(bad("ellipse", "an isocircle's `plane` is left, top or right")), + None => s.settings.isoplane, + }; + let e = iso::isocircle(plane, center, r).ok_or_else(|| bad("ellipse", "the isocircle needs a finite center and a positive radius"))?; + let h = s.add_entity(ellipse_entity(&e))?; + Ok(json!({ "handle": h.hex(), "plane": plane.name() })) +} + +pub(crate) fn ellipse_entity(e: &Ellipse) -> cadcraft_doc::EntityKind { + cadcraft_doc::EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: e.center.to3(0.0), + major: e.major.to3(0.0), + ratio: e.ratio, + start: 0.0, + end: cadcraft_geom::TAU, + }) +} + +/// ELLIPSE's Isocircle option: the center, then the radius (or diameter) in the current +/// isometric plane. [`IsoCircle::input`] gives the finished ellipse. +#[derive(Default)] +pub(crate) struct IsoCircle { + center: Option, + diameter: bool, +} + +impl IsoCircle { + pub(crate) fn prompt(&self) -> Prompt { + match self.center { + None => Prompt::new("Specify center of isocircle", Accept::POINT), + Some(c) if self.diameter => Prompt::new("Specify diameter of isocircle", Accept::POINT_OR_NUMBER).base(c), + Some(c) => Prompt::new("Specify radius of isocircle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base(c), + } + } + + /// The ellipse once the input completes it. + pub(crate) fn input(&mut self, s: &Session, i: Input) -> Result> { + let plane = s.settings.isoplane; + let Some(c) = self.center else { + if let Input::Point(p) = i { + self.center = Some(p); + } + return Ok(None); + }; + let r = match i { + Input::Keyword(k) if k == "Diameter" => { + self.diameter = true; + return Ok(None); + } + Input::Point(p) => iso::iso_radius(plane, c, p).map(|r| if self.diameter { r / 2.0 } else { r }), + Input::Text(t) => { + let d = crate::units::parse_distance(&t).ok_or_else(|| EngineError::Other("Requires a distance.".into()))?; + Some(if self.diameter { d / 2.0 } else { d }) + } + _ => return Ok(None), + }; + Ok(r.and_then(|r| iso::isocircle(plane, c, r))) + } + + pub(crate) fn preview(&self, s: &Session, cursor: Vec2) -> Option { + let c = self.center?; + let plane = s.settings.isoplane; + let r = iso::iso_radius(plane, c, cursor)?; + iso::isocircle(plane, c, if self.diameter { r / 2.0 } else { r }) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6fb44cd..464086a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -361,7 +361,10 @@ pub struct Settings { pub cursorsize: f64, /// MAXARRAY: the most objects one array command creates (items × selected objects). pub maxarray: u64, + /// SNAPSTYL 1: isometric snap and grid, ortho and crosshair along the isometric plane's axes. pub isodraft: bool, + /// SNAPISOPAIR: the current isometric plane (ISOPLANE, F5). + pub isoplane: snap::iso::IsoPlane, pub annoallvisible: bool, pub annoautoscale: bool, /// QPMODE: show the Quick Properties palette when objects are selected. @@ -413,6 +416,7 @@ impl Default for Settings { cursorsize: 5.0, maxarray: 100_000, isodraft: false, + isoplane: snap::iso::IsoPlane::Left, annoallvisible: true, annoautoscale: false, qpmode: false, @@ -1255,7 +1259,7 @@ impl Session { { let mut c = self.cursor; if self.settings.orthomode { - c = snap::ortho(base, c); + c = snap::iso::constrain(&self.settings, base, c); } let dir = (c - base).normalized(); let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 54ce34a..6154f30 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -1,6 +1,7 @@ //! Object snaps (OSNAP), polar tracking and ortho. mod curves; +pub mod iso; pub mod tracking; use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space}; diff --git a/crates/engine/src/snap/iso.rs b/crates/engine/src/snap/iso.rs new file mode 100644 index 0000000..b4ee783 --- /dev/null +++ b/crates/engine/src/snap/iso.rs @@ -0,0 +1,291 @@ +//! Isometric drafting: the isometric snap lattice and grid (SNAPSTYL 1), the current isometric +//! plane (SNAPISOPAIR, ISOPLANE), ortho along the plane's axes and isometric circles. +//! +//! Isometric axes run at 30°, 90° and 150°. Each plane is drawn on two of them: Left on 90° and +//! 150°, Top on 30° and 150°, Right on 30° and 90°. Lengths along the axes are true lengths, so +//! the snap lattice is triangular: its points sit the snap Y spacing apart along every axis. + +use cadcraft_geom::{Bounds2, Ellipse, Vec2}; +use serde::{Deserialize, Serialize}; + +use crate::Settings; + +/// cos 30°. +const COS30: f64 = 0.866_025_403_784_438_6; +/// The most lines one family of the isometric grid draws. +pub const MAX_GRID_LINES: usize = 2000; + +/// The current isometric plane (SNAPISOPAIR 0, 1, 2). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IsoPlane { + #[default] + Left, + Top, + Right, +} + +impl IsoPlane { + pub const ALL: [IsoPlane; 3] = [IsoPlane::Left, IsoPlane::Top, IsoPlane::Right]; + + /// SNAPISOPAIR. + pub fn index(self) -> i64 { + match self { + IsoPlane::Left => 0, + IsoPlane::Top => 1, + IsoPlane::Right => 2, + } + } + + pub fn from_index(i: i64) -> Option { + IsoPlane::ALL.get(usize::try_from(i).ok()?).copied() + } + + /// The plane a keyword or name means (`left`, `t`…), case-insensitive. + pub fn parse(text: &str) -> Option { + let t = text.trim().to_ascii_lowercase(); + if t.is_empty() { + return None; + } + IsoPlane::ALL.into_iter().find(|p| p.name().to_ascii_lowercase().starts_with(&t)) + } + + pub fn name(self) -> &'static str { + match self { + IsoPlane::Left => "Left", + IsoPlane::Top => "Top", + IsoPlane::Right => "Right", + } + } + + /// The plane F5 / Ctrl+E switches to: Left, Top, Right, Left… + pub fn next(self) -> IsoPlane { + match self { + IsoPlane::Left => IsoPlane::Top, + IsoPlane::Top => IsoPlane::Right, + IsoPlane::Right => IsoPlane::Left, + } + } + + /// The directions (radians) of the plane's two axes. + pub fn axes(self) -> [f64; 2] { + let d = f64::to_radians; + match self { + IsoPlane::Left => [d(90.0), d(150.0)], + IsoPlane::Top => [d(30.0), d(150.0)], + IsoPlane::Right => [d(30.0), d(90.0)], + } + } +} + +/// The plane's axis directions when isometric drafting is on, else the X and Y axes (radians). +pub fn cursor_axes(s: &Settings) -> [f64; 2] { + if s.isodraft { s.isoplane.axes() } else { [0.0, std::f64::consts::FRAC_PI_2] } +} + +/// The isometric lattice spacing: the snap Y spacing (a positive finite number, or 1). +fn spacing(d: f64) -> Option { + (d.is_finite() && d > 1e-12).then_some(d) +} + +/// The nearest point of the isometric lattice of `d` spacing through `origin`, turned by `angle` +/// (radians) about it. The lattice's points are `i·(d cos 30°, d sin 30°) + j·(0, d)`. +pub fn lattice_snap(p: Vec2, d: f64, origin: Vec2, angle: f64) -> Vec2 { + let Some(d) = spacing(d) else { return p }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + let q = (p - origin).rotate(-angle); + let (u, v) = (Vec2::new(d * COS30, d * 0.5), Vec2::new(0.0, d)); + // Lattice coordinates; the nearest point is a corner of the cell holding `q`. + let i = q.x / u.x; + let j = (q.y - i * u.y) / d; + if !(i.is_finite() && j.is_finite()) { + return p; + } + let (i0, j0) = (i.floor(), j.floor()); + let mut best = (f64::INFINITY, q); + for (di, dj) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] { + let c = u * (i0 + di) + v * (j0 + dj); + let dd = c.dist(q); + if dd < best.0 { + best = (dd, c); + } + } + let s = origin + best.1.rotate(angle); + if s.is_finite() { s } else { p } +} + +/// Grid snap for a cursor update: with snap on and isometric style, the cursor snapped to the +/// isometric lattice, and the settings with grid snap off so the rectangular grid leaves it be. +/// Otherwise both unchanged. +pub(crate) fn grid_snap(doc: &cadcraft_doc::Drawing, mut s: Settings, p: Vec2) -> (Vec2, Settings) { + if !(s.snapmode && s.isodraft) { + return (p, s); + } + let (origin, angle) = super::grid_frame(doc); + s.snapmode = false; + (lattice_snap(p, s.snapunit.y, origin, angle), s) +} + +/// The plane axis (unit vector, either way along it) nearest the direction `base`→`p`. +fn nearest_axis(plane: IsoPlane, d: Vec2) -> Vec2 { + let mut best = (f64::NEG_INFINITY, Vec2::X); + for a in plane.axes() { + let dir = Vec2::from_angle(a); + let dot = d.dot(dir); + let (k, dir) = if dot < 0.0 { (-dot, dir * -1.0) } else { (dot, dir) }; + if k > best.0 { + best = (k, dir); + } + } + best.1 +} + +/// Ortho without snapping: `p` constrained relative to `base` to the nearest axis of the current +/// isometric plane (or to horizontal/vertical when isometric drafting is off). +pub fn constrain(s: &Settings, base: Vec2, p: Vec2) -> Vec2 { + if !s.isodraft { + return super::ortho(base, p); + } + let dir = nearest_axis(s.isoplane, p - base); + let r = base + dir * (p - base).dot(dir); + if r.is_finite() { r } else { p } +} + +/// Ortho at the cursor: [`constrain`], and with isometric snap on, whole snap steps along the +/// axis from `base`. +pub fn ortho(s: &Settings, base: Vec2, p: Vec2) -> Vec2 { + let r = constrain(s, base, p); + match spacing(s.snapunit.y).filter(|_| s.isodraft && s.snapmode) { + Some(d) => { + let t = r.dist(base); + let dir = (r - base).normalized(); + let snapped = base + dir * ((t / d).round() * d); + if snapped.is_finite() { snapped } else { r } + } + None => r, + } +} + +/// The coordinates of `v` in the plane's axis basis (`v = a·axis0 + b·axis1`). +fn plane_coords(plane: IsoPlane, v: Vec2) -> Option<(f64, f64)> { + let [a0, a1] = plane.axes().map(Vec2::from_angle); + let det = a0.cross(a1); + if det.abs() < 1e-12 { + return None; + } + let a = v.cross(a1) / det; + let b = a0.cross(v) / det; + (a.is_finite() && b.is_finite()).then_some((a, b)) +} + +/// The radius, measured in the isometric plane, of the isocircle about `center` through `p`. +pub fn iso_radius(plane: IsoPlane, center: Vec2, p: Vec2) -> Option { + let (a, b) = plane_coords(plane, p - center)?; + let r = a.hypot(b); + (r.is_finite() && r > 1e-12).then_some(r) +} + +/// A circle of `radius` in the isometric plane, as drawn: an ellipse whose minor axis runs along +/// the isometric axis the plane doesn't use, with ratio 1/√3 and semi-major axis radius·√(3/2). +pub fn isocircle(plane: IsoPlane, center: Vec2, radius: f64) -> Option { + if !(radius.is_finite() && radius > 1e-12 && center.is_finite()) { + return None; + } + let other = match plane { + IsoPlane::Left => 30.0f64, + IsoPlane::Top => 90.0, + IsoPlane::Right => 150.0, + }; + let major = Vec2::from_angle((other + 90.0).to_radians()) * (radius * 1.5f64.sqrt()); + Some(Ellipse::full(center, major, 1.0 / 3.0f64.sqrt())) +} + +/// One line of the isometric grid, clipped to the view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridLine { + pub a: Vec2, + pub b: Vec2, + pub major: bool, +} + +/// The isometric grid lines (30°, 90° and 150° families through the lattice of `d` spacing at +/// `origin`, turned by `angle`) that cross `view`; every `major_every`th line of a family is a +/// major line. Empty when a family would need more than [`MAX_GRID_LINES`] lines. +pub fn grid_lines(view: Bounds2, d: f64, origin: Vec2, angle: f64, major_every: u32) -> Vec { + let Some(d) = spacing(d) else { return Vec::new() }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + if !(view.min.is_finite() && view.max.is_finite() && origin.is_finite()) { + return Vec::new(); + } + let corners = [view.min, Vec2::new(view.max.x, view.min.y), view.max, Vec2::new(view.min.x, view.max.y)]; + // Neighbouring lines of a family are d·cos 30° apart. + let gap = d * COS30; + let major_every = i64::from(major_every.max(1)); + let mut out = Vec::new(); + for deg in [30.0f64, 90.0, 150.0] { + let dir = Vec2::from_angle(deg.to_radians() + angle); + let n = dir.perp(); + let off = origin.dot(n); + let (lo, hi) = corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| { + let k = (c.dot(n) - off) / gap; + (lo.min(k), hi.max(k)) + }); + let (k0, k1) = (lo.floor(), hi.ceil()); + if !(k0.is_finite() && k1.is_finite()) || k1 - k0 > MAX_GRID_LINES as f64 { + return Vec::new(); + } + let (k0, k1) = (k0 as i64, k1 as i64); + for k in k0..=k1 { + let p0 = n * (off + k as f64 * gap); + if let Some((a, b)) = clip(view, p0, dir) { + out.push(GridLine { a, b, major: k.rem_euclid(major_every) == 0 }); + } + } + } + out +} + +/// The part of the line through `p` along unit `dir` inside `view` (Liang–Barsky). +fn clip(view: Bounds2, p: Vec2, dir: Vec2) -> Option<(Vec2, Vec2)> { + let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY); + for (pc, dc, lo, hi) in [(p.x, dir.x, view.min.x, view.max.x), (p.y, dir.y, view.min.y, view.max.y)] { + if dc.abs() < 1e-15 { + if pc < lo || pc > hi { + return None; + } + } else { + let (a, b) = ((lo - pc) / dc, (hi - pc) / dc); + t0 = t0.max(a.min(b)); + t1 = t1.min(a.max(b)); + } + } + (t0.is_finite() && t1.is_finite() && t1 > t0).then(|| (p + dir * t0, p + dir * t1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lattice_points_are_spacing_apart_along_each_axis() { + let d = 2.0; + for deg in [30.0f64, 90.0, 150.0] { + let p = Vec2::from_angle(deg.to_radians()) * (3.0 * d); + assert!(lattice_snap(p + Vec2::new(0.1, -0.05), d, Vec2::ZERO, 0.0).near(p, 1e-9)); + } + // Halfway along a lattice step is not a lattice point. + let h = Vec2::new(0.0, 3.0); + let s = lattice_snap(h, d, Vec2::ZERO, 0.0); + assert!(s.near(Vec2::new(0.0, 2.0), 1e-9) || s.near(Vec2::new(0.0, 4.0), 1e-9)); + } + + #[test] + fn grid_lines_bounded() { + let v = Bounds2 { min: Vec2::new(-10.0, -10.0), max: Vec2::new(10.0, 10.0) }; + let lines = grid_lines(v, 1.0, Vec2::ZERO, 0.0, 5); + assert!(!lines.is_empty()); + assert!(lines.iter().all(|l| l.a.is_finite() && l.b.is_finite())); + assert!(grid_lines(v, 1e-9, Vec2::ZERO, 0.0, 5).is_empty()); + assert!(grid_lines(v, f64::NAN, Vec2::ZERO, 0.0, 5).is_empty()); + } +} diff --git a/crates/engine/src/snap/tracking.rs b/crates/engine/src/snap/tracking.rs index 0ff00dd..38d4d7d 100644 --- a/crates/engine/src/snap/tracking.rs +++ b/crates/engine/src/snap/tracking.rs @@ -767,6 +767,8 @@ impl crate::Session { self.tracking.last = None; return CursorSnap { point: h.point, snap: Some(h), track: None, wait }; } + // Isometric snap (SNAPSTYL 1): its lattice in place of the rectangular grid. + let (raw, s) = super::iso::grid_snap(&doc, s, raw); let mut p = raw; if s.snapmode && !s.polarsnap { let (origin, angle) = super::grid_frame(&doc); @@ -787,7 +789,7 @@ impl crate::Session { if let Some(b) = q.base && s.orthomode { - p = super::ortho(b, p); + p = super::iso::ortho(&self.settings, b, p); } CursorSnap { point: p, snap: None, track: None, wait } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index b1cb382..ede3cf5 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -20,6 +20,8 @@ const SESSION_VARS: &[&str] = &[ "QNEWTEMPLATE", "GRIDMODE", "SNAPMODE", + "SNAPSTYL", + "SNAPISOPAIR", "SNAPUNIT", "GRIDUNIT", "UNDOCTL", @@ -71,6 +73,8 @@ pub fn get(s: &Session, name: &str) -> Option { "POLARANG" => json!(st.polarang.to_degrees()), "GRIDMODE" => json!(i32::from(st.gridmode)), "SNAPMODE" => json!(i32::from(st.snapmode)), + "SNAPSTYL" => json!(i32::from(st.isodraft)), + "SNAPISOPAIR" => json!(st.isoplane.index()), "SNAPUNIT" => json!([st.snapunit.x, st.snapunit.y]), "GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]), "GRIDMAJOR" => json!(st.gridmajor), @@ -215,6 +219,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "POLARANG" => st.polarang = as_f64(v).filter(|a| *a > 0.0).ok_or_else(bad)?.to_radians(), "GRIDMODE" => st.gridmode = as_bool(v).ok_or_else(bad)?, "SNAPMODE" => st.snapmode = as_bool(v).ok_or_else(bad)?, + "SNAPSTYL" => st.isodraft = as_i(v).filter(|b| (0..=1).contains(b)).ok_or_else(bad)? == 1, + "SNAPISOPAIR" => st.isoplane = as_i(v).and_then(crate::snap::iso::IsoPlane::from_index).ok_or_else(bad)?, "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, diff --git a/crates/engine/tests/isometric.rs b/crates/engine/tests/isometric.rs new file mode 100644 index 0000000..911e308 --- /dev/null +++ b/crates/engine/tests/isometric.rs @@ -0,0 +1,252 @@ +//! Isometric drafting: ISODRAFT and ISOPLANE (SNAPSTYL, SNAPISOPAIR), the isometric snap +//! lattice, ortho along the isometric plane's axes and ELLIPSE's Isocircle option. + +use cadcraft_doc::EntityKind; +use cadcraft_engine::Session; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::snap::iso::{self, IsoPlane}; +use cadcraft_engine::snap::tracking::CursorQuery; +use serde_json::{Value, json}; + +fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +fn deg(a: f64) -> Vec2 { + Vec2::from_angle(a.to_radians()) +} + +fn getvar(s: &Session, name: &str) -> Value { + cadcraft_engine::sysvars::get(s, name).unwrap_or(Value::Null) +} + +fn setvar(s: &mut Session, name: &str, value: Value) -> cadcraft_engine::Result { + s.execute("setvar", &json!({ "name": name, "value": value })) +} + +fn last_ellipse(s: &Session) -> cadcraft_doc::Ellipse { + match s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) { + Some(EntityKind::Ellipse(e)) => e, + other => panic!("not an ellipse: {other:?}"), + } +} + +/// Whether `p` lies on the full ellipse. +fn on_ellipse(e: &cadcraft_doc::Ellipse, p: Vec2) -> bool { + let (c, m) = (e.center.xy(), e.major.xy()); + let (a, b) = (m.len(), m.len() * e.ratio); + let u = m.normalized(); + let q = p - c; + let (x, y) = (q.dot(u) / a, q.dot(u.perp()) / b); + (x * x + y * y - 1.0).abs() < 1e-9 +} + +fn cursor(s: &mut Session, raw: Vec2, base: Option) -> Vec2 { + s.snap_cursor(raw, &CursorQuery { base, deferred: false, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }).point +} + +#[test] +fn isodraft_and_isoplane_set_snapstyl_and_snapisopair() { + let mut s = Session::new(); + assert_eq!(getvar(&s, "SNAPSTYL"), json!(0)); + assert_eq!(getvar(&s, "SNAPISOPAIR"), json!(0)); + + let r = s.execute("isodraft", &json!({ "plane": "top" })).unwrap(); + assert_eq!(r["message"], ""); + assert_eq!((getvar(&s, "SNAPSTYL"), getvar(&s, "SNAPISOPAIR")), (json!(1), json!(1))); + s.execute("isodraft", &json!({ "plane": "orthographic" })).unwrap(); + assert_eq!(getvar(&s, "SNAPSTYL"), json!(0)); + assert_eq!(getvar(&s, "SNAPISOPAIR"), json!(1), "the plane is kept"); + // The status-bar button toggles. + s.execute("isodraft", &json!({})).unwrap(); + assert!(s.settings.isodraft); + s.execute("isodraft", &json!({ "on": false })).unwrap(); + assert!(!s.settings.isodraft); + + // ISOPLANE (F5) cycles Left, Top, Right. + let planes: Vec = (0..3).map(|_| s.execute("isoplane", &json!({})).unwrap()["plane"].as_str().unwrap().to_string()).collect(); + assert_eq!(planes, ["Right", "Left", "Top"]); + s.execute("isoplane", &json!({ "plane": "right" })).unwrap(); + assert_eq!(s.settings.isoplane, IsoPlane::Right); + + // SETVAR reaches both; bad values are refused. + setvar(&mut s, "SNAPSTYL", json!(1)).unwrap(); + setvar(&mut s, "SNAPISOPAIR", json!(0)).unwrap(); + assert!(s.settings.isodraft && s.settings.isoplane == IsoPlane::Left); + assert!(setvar(&mut s, "SNAPSTYL", json!(2)).is_err()); + assert!(setvar(&mut s, "SNAPISOPAIR", json!(3)).is_err()); + assert!(setvar(&mut s, "SNAPISOPAIR", json!(-1)).is_err()); + assert!(s.execute("isodraft", &json!({ "plane": "front" })).is_err()); + assert!(s.execute("isoplane", &json!({ "plane": "orthographic" })).is_err()); + // Not a drawing header variable. + assert!(s.doc().unwrap().header.get("SNAPSTYL").is_none()); +} + +#[test] +fn isodraft_and_isoplane_at_the_command_line() { + let mut s = Session::new(); + s.cmdline("isodraft r").unwrap(); + assert!(s.settings.isodraft && s.settings.isoplane == IsoPlane::Right); + assert!(s.log.iter().any(|l| l.contains(""))); + s.cmdline("isoplane").unwrap(); + assert_eq!(s.current_prompt().unwrap().default.as_deref(), Some("Left"), "Enter takes the next plane"); + s.cmdline("").unwrap(); + assert_eq!(s.settings.isoplane, IsoPlane::Left); + s.cmdline("isoplane t").unwrap(); + assert_eq!(s.settings.isoplane, IsoPlane::Top); + // Enter at ISODRAFT toggles: back to orthographic. + s.cmdline("isodraft").unwrap(); + s.cmdline("").unwrap(); + assert!(!s.settings.isodraft); + s.cmdline("isodraft o").unwrap(); + assert!(!s.settings.isodraft); + // Switching the plane mid-command leaves the command running. + s.cmdline("line 0,0").unwrap(); + s.execute("isoplane", &json!({})).unwrap(); + assert!(s.running.is_some()); +} + +#[test] +fn isometric_snap_follows_the_lattice() { + let mut s = Session::new(); + s.execute("snap", &json!({ "on": true })).unwrap(); + setvar(&mut s, "SNAPUNIT", json!([1.0, 2.0])).unwrap(); + // Rectangular: the X and Y spacing. + assert!(cursor(&mut s, v(0.9, 2.2), None).near(v(1.0, 2.0), 1e-12)); + s.execute("isodraft", &json!({ "plane": "left" })).unwrap(); + // Isometric: the Y spacing along the 30°, 90° and 150° axes. + for (a, k) in [(30.0, 1.0), (90.0, 2.0), (150.0, 3.0), (210.0, 1.0), (330.0, 2.0)] { + let target = deg(a) * (2.0 * k); + let got = cursor(&mut s, target + v(0.13, -0.11), None); + assert!(got.near(target, 1e-9), "{a}° × {k}: {got:?}"); + } + // (1, 2) is a rectangular grid point but not on the isometric lattice. + let got = cursor(&mut s, v(1.0, 2.0), None); + assert!(!got.near(v(1.0, 2.0), 1e-6)); + assert!(got.near(v(0.0, 2.0), 1e-9), "{got:?}"); + // Snap off: the cursor is free. + s.execute("snap", &json!({ "on": false })).unwrap(); + assert!(cursor(&mut s, v(1.0, 2.0), None).near(v(1.0, 2.0), 1e-12)); +} + +#[test] +fn ortho_follows_the_isometric_plane_axes() { + let mut s = Session::new(); + s.execute("ortho", &json!({ "on": true })).unwrap(); + s.execute("polar", &json!({ "on": false })).unwrap(); + let base = v(1.0, 1.0); + // Off: horizontal and vertical. + assert!(cursor(&mut s, base + deg(25.0) * 3.0, Some(base)).near(base + v(3.0 * 25f64.to_radians().cos(), 0.0), 1e-9)); + for (plane, raw, axis) in [ + ("top", 25.0, 30.0), + ("top", 160.0, 150.0), + ("top", 100.0, 150.0), + ("left", 25.0, 330.0), + ("left", 80.0, 90.0), + ("right", 25.0, 30.0), + ("right", 200.0, 210.0), + ] { + s.execute("isodraft", &json!({ "plane": plane })).unwrap(); + let got = cursor(&mut s, base + deg(raw) * 3.0, Some(base)); + let dir = (got - base).normalized(); + assert!(dir.near(deg(axis), 1e-9), "{plane} {raw}°: {dir:?}"); + } + // With isometric snap, whole snap steps along the axis. + s.execute("isodraft", &json!({ "plane": "top" })).unwrap(); + s.execute("snap", &json!({ "on": true })).unwrap(); + setvar(&mut s, "SNAPUNIT", json!([0.5, 0.5])).unwrap(); + let got = cursor(&mut s, base + deg(27.0) * 1.37, Some(base)); + assert!(got.near(base + deg(30.0) * 1.5, 1e-9), "{got:?}"); + + // Direct distance entry runs along the plane's axis too. + s.execute("snap", &json!({ "on": false })).unwrap(); + s.cmdline("line 0,0").unwrap(); + s.cursor = deg(140.0) * 5.0; + s.cmdline("2").unwrap(); + s.cmdline("").unwrap(); + match s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) { + Some(EntityKind::Line(l)) => assert!(l.b.xy().near(deg(150.0) * 2.0, 1e-9), "{:?}", l.b), + other => panic!("not a line: {other:?}"), + } +} + +#[test] +fn ellipse_isocircle_in_each_plane() { + let mut s = Session::new(); + // Only offered with the isometric snap style. + s.cmdline("ellipse").unwrap(); + assert!(!s.current_prompt().unwrap().keywords.iter().any(|k| k == "Isocircle")); + s.running = None; + + s.execute("isodraft", &json!({ "plane": "top" })).unwrap(); + s.cmdline("ellipse").unwrap(); + assert!(s.current_prompt().unwrap().keywords.iter().any(|k| k == "Isocircle")); + s.cmdline("i 3,4 2").unwrap(); + assert!(s.running.is_none()); + let e = last_ellipse(&s); + assert!(e.center.xy().near(v(3.0, 4.0), 1e-12)); + assert!((e.ratio - 1.0 / 3f64.sqrt()).abs() < 1e-12); + assert!((e.major.xy().len() - 2.0 * 1.5f64.sqrt()).abs() < 1e-12); + assert!(e.major.xy().normalized().cross(Vec2::X).abs() < 1e-12, "top isocircles lie flat"); + // It meets the plane's axes at the radius. + for a in [30.0, 150.0, 210.0, 330.0] { + assert!(on_ellipse(&e, v(3.0, 4.0) + deg(a) * 2.0), "{a}°"); + } + + // Left plane by diameter; the Right plane through a picked point. + s.execute("isoplane", &json!({ "plane": "left" })).unwrap(); + s.cmdline("ellipse i 0,0 d 4").unwrap(); + let e = last_ellipse(&s); + for a in [90.0, 150.0, 270.0, 330.0] { + assert!(on_ellipse(&e, deg(a) * 2.0), "left {a}°"); + } + s.execute("isoplane", &json!({ "plane": "right" })).unwrap(); + let p = deg(30.0) * 1.0 + deg(90.0) * 2.0; + s.cmdline(&format!("ellipse i 0,0 {},{}", p.x, p.y)).unwrap(); + let e = last_ellipse(&s); + assert!(on_ellipse(&e, p)); + assert!(on_ellipse(&e, deg(90.0) * 5f64.sqrt())); + + // An isometric arc: the Arc option then Isocircle, then start and end angles. + s.cmdline("ellipse a i 0,0 1 0 90").unwrap(); + assert!(s.running.is_none()); + let e = last_ellipse(&s); + assert!((e.end - e.start).abs() > 1e-6 && (e.end - e.start).abs() < cadcraft_geom::TAU - 1e-6); + + // JSON form. + let r = s.execute("ellipse", &json!({ "isocircle": { "center": [1, 1], "radius": 1, "plane": "top" } })).unwrap(); + assert_eq!(r["plane"], "Top"); + assert!(on_ellipse(&last_ellipse(&s), v(1.0, 1.0) + deg(30.0))); + for bad in + [json!({ "center": [0, 0], "radius": -1 }), json!({ "center": [0, 0], "radius": 0 }), json!({ "center": [0, 0] }), json!({ "radius": 1 })] + { + assert!(s.execute("ellipse", &json!({ "isocircle": bad })).is_err(), "{bad}"); + } + assert!(s.execute("ellipse", &json!({ "isocircle": { "center": [0, 0], "radius": 1, "plane": "orthographic" } })).is_err()); +} + +#[test] +fn hostile_isometric_inputs_never_panic() { + for d in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] { + let p = iso::lattice_snap(v(3.0, 4.0), d, Vec2::ZERO, 0.0); + assert!(p.is_finite()); + let p = iso::lattice_snap(v(f64::MAX, -f64::MAX), d, Vec2::ZERO, f64::NAN); + let _ = p; + } + let view = cadcraft_geom::Bounds2 { min: v(-1e12, -1e12), max: v(1e12, 1e12) }; + assert!(iso::grid_lines(view, 1.0, Vec2::ZERO, 0.0, 5).is_empty(), "too many lines"); + let view = cadcraft_geom::Bounds2 { min: v(-10.0, -10.0), max: v(10.0, 10.0) }; + let lines = iso::grid_lines(view, 1.0, Vec2::ZERO, 0.3, 0); + assert!(!lines.is_empty() && lines.len() <= 3 * (iso::MAX_GRID_LINES + 1)); + for plane in IsoPlane::ALL { + assert!(iso::isocircle(plane, Vec2::ZERO, f64::NAN).is_none()); + assert!(iso::isocircle(plane, v(f64::NAN, 0.0), 1.0).is_none()); + assert!(iso::iso_radius(plane, Vec2::ZERO, Vec2::ZERO).is_none()); + } + let mut s = Session::new(); + s.execute("isodraft", &json!({ "plane": "top" })).unwrap(); + for t in ["ellipse i 0,0 abc", "ellipse i 0,0 -5", "ellipse i 0,0 1e400"] { + let _ = s.cmdline(t); + s.running = None; + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 601f8cd..1a637ab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,7 +183,9 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { let t = Tokens::get(); let s = &app.session.settings; let wb = xf.world_bounds(); - if s.gridmode { + if s.gridmode && s.isodraft { + draw_iso_grid(app, p, xf); + } else if s.gridmode { let major_n = f64::from(s.gridmajor.max(1)); let minor = adaptive(s.gridunit.x, xf.scale, 8.0); let major = minor * major_n; @@ -224,6 +226,28 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { } } +/// The isometric grid (SNAPSTYL 1): lines at 30°, 90° and 150° through the snap lattice, GRIDUNIT +/// Y apart along the axes, coarsened while they would crowd closer than 8 pixels. +fn draw_iso_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { + use cadcraft_engine::snap::iso; + let t = Tokens::get(); + let s = &app.session.settings; + let mut d = s.gridunit.y.max(1e-12); + for _ in 0..40 { + if d * 0.866 * xf.scale >= 8.0 { + break; + } + d *= 5.0; + } + let (origin, angle) = app.session.doc().map(cadcraft_engine::snap::grid_frame).unwrap_or((Vec2::ZERO, 0.0)); + let lines = iso::grid_lines(xf.world_bounds(), d, origin, angle, s.gridmajor); + p.extend( + lines.into_iter().map(|l| { + Shape::line_segment([xf.to_screen(l.a), xf.to_screen(l.b)], Stroke::new(1.0, if l.major { t.grid_major } else { t.grid_minor })) + }), + ); +} + fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> { let wb = xf.world_bounds(); let far = (wb.width() + wb.height()) * 2.0 + base.dist(wb.center()); @@ -1120,10 +1144,19 @@ fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox let len = (rect.height() * app.session.settings.cursorsize as f32 / 100.0).max(12.0); let pb = if pickbox { pickbox_half(app.session.settings.pickbox) } else { None }; let gap = pb.unwrap_or(0.0); - p.line_segment([pos2(at.x - len, at.y), pos2(at.x - gap, at.y)], st); - p.line_segment([pos2(at.x + gap, at.y), pos2(at.x + len, at.y)], st); - p.line_segment([pos2(at.x, at.y - len), pos2(at.x, at.y - gap)], st); - p.line_segment([pos2(at.x, at.y + gap), pos2(at.x, at.y + len)], st); + // Along the isometric plane's axes in isometric drafting. + if app.session.settings.isodraft { + for a in cadcraft_engine::snap::iso::cursor_axes(&app.session.settings) { + let dir = vec2(a.cos() as f32, -(a.sin() as f32)); + p.line_segment([at - dir * len, at - dir * gap], st); + p.line_segment([at + dir * gap, at + dir * len], st); + } + } else { + p.line_segment([pos2(at.x - len, at.y), pos2(at.x - gap, at.y)], st); + p.line_segment([pos2(at.x + gap, at.y), pos2(at.x + len, at.y)], st); + p.line_segment([pos2(at.x, at.y - len), pos2(at.x, at.y - gap)], st); + p.line_segment([pos2(at.x, at.y + gap), pos2(at.x, at.y + len)], st); + } if let Some(pb) = pb { p.rect_stroke(Rect::from_center_size(at, vec2(pb * 2.0, pb * 2.0)), 0.0, st, egui::StrokeKind::Middle); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 293cb54..ace0e14 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -38,6 +38,11 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let s = &mut app.session.settings; ui.heading(crate::tl!("Snap and Grid")); ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)")); + // SNAPSTYL: the isometric snap and grid (ISODRAFT). + ui.horizontal(|ui| { + ui.radio_value(&mut s.isodraft, false, crate::tl!("Rectangular snap")); + ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap")); + }); ui.horizontal(|ui| { ui.label(crate::tl!("Snap X spacing")); ui.add(egui::DragValue::new(&mut s.snapunit.x).speed(0.05).range(0.0001..=1e6)); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 66bd7ab..0ccdab9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -427,6 +427,8 @@ Join Об'єднати Join the ArtCraft Discord… Приєднатися до Discord ArtCraft… Join us on Discord Приєднуйтеся до нас у Discord + Isometric Plane Ізометрична площина + Isometric snap Ізометрична крокова прив'язка Language Мова Last Останній Last commit Останній коміт @@ -688,6 +690,7 @@ Rectangle Прямокутник Recalculate Table Formulas Перерахувати формули таблиці Rectangular Прямокутний + Rectangular snap Прямокутна крокова прив'язка Rectangular Array Прямокутний масив Red Червоний Redo Повторити diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d11ee46..f4e14a2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -517,6 +517,8 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(Modifiers::NONE, Key::F1), "ui.dialog.commands"), (sc(Modifiers::NONE, Key::F2), "ui.toggle.history"), (sc(Modifiers::NONE, Key::F3), "osnap"), + (sc(Modifiers::NONE, Key::F5), "isoplane"), + (sc(cmd, Key::E), "isoplane"), (sc(Modifiers::NONE, Key::F7), "grid"), (sc(Modifiers::NONE, Key::F8), "ortho"), (sc(Modifiers::NONE, Key::F9), "snap"), @@ -538,6 +540,13 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { } }); for id in fire { + // ISOPLANE (F5, Ctrl+E) switches the plane without cancelling the active command. + if id == "isoplane" { + if let Some(m) = app.session.execute(id, &json!({})).ok().and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) { + app.session.echo(m); + } + continue; + } // Toggles run transparently (they don't cancel the active command). let transparent = ["osnap", "grid", "ortho", "snap", "polar", "otrack", "dynmode", "pickstyle"].contains(&id); if transparent || id.starts_with("ui.") { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1008b6e..43d4f64 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -88,9 +88,9 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours** | Additional polar angles, relative to last segment | yes | additional angles yes (POLARADDANG with POLARMODE 4); **relative to the last segment no** | | Polar distance snap (PolarSnap) | yes | **no** | | **Object snap tracking** (acquire points, alignment paths, intersections of paths) | yes, F11 | yes (#379): resting on a snap point acquires it (`+`), resting again releases it, seven at most; horizontal/vertical paths, or every polar angle with POLARMODE 2; Shift to acquire with POLARMODE 8; crossings of two paths, a path and an object, and a path and a polar path snap; tooltip names each path; a typed distance (direct distance entry) runs along the path the cursor is on, measured from the point the path comes from. Engine: `Session::snap_cursor`, `snap/tracking.rs`; AUTOSNAP bits 8 and 16 | -| Grid (rectangular, major lines, adaptive, limits) | yes | rectangular, major lines, adaptive display | -| Snap (rectangular, isometric, polar snap) | yes | rectangular from 0,0 only (`snap.rs:531`) | -| Isometric drafting (ISODRAFT, isoplanes F5) | yes | **status-bar toggle with no effect** | +| Grid (rectangular, major lines, adaptive, limits) | yes | rectangular and isometric (30°, 90° and 150° lines), major lines, adaptive display | +| Snap (rectangular, isometric, polar snap) | yes | rectangular and isometric (SNAPSTYL 1: the snap Y spacing along the isometric axes), from SNAPBASE turned by SNAPANG | +| Isometric drafting (ISODRAFT, isoplanes F5) | yes | yes (#556): ISODRAFT and the status-bar toggle set SNAPSTYL and the plane; ISOPLANE / F5 / Ctrl+E cycle Left, Top, Right (SNAPISOPAIR); ortho, direct distance entry and the crosshair follow the plane's axes; ELLIPSE Isocircle (radius or Diameter, also for elliptical arcs); Drafting Settings snap style radio. Engine: `snap/iso.rs`, `cmd/settings/iso.rs` | | Drafting Settings dialog | yes | yes (`dialogs.rs:36-80`) | ## Dynamic input (≈ 45%) @@ -184,9 +184,9 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. ## Keyboard and context menus (≈ 25%) -- Shortcut table: 25 entries (Cmd shortcuts, F1, F2, F3, F7–F12) plus 21 command `.key()` - shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap), F5 (isoplane), F6 - (dynamic UCS) are missing. +- Shortcut table: 28 entries (Cmd shortcuts, F1, F2, F3, F5, F7–F12) plus 23 command `.key()` + shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap) and F6 (dynamic UCS) + are missing. - Delete erases the selection on macOS (#64); Escape cancels. - Right-click on the canvas is Enter while a command runs; holding it (250 ms, AutoCAD's time-sensitive right-click) opens the command menu: Enter, Cancel, Recent Input, the prompt's @@ -201,6 +201,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Isometric drafting (#556): isometric snap and grid, ISODRAFT, ISOPLANE (F5), ortho along isoplane axes, ELLIPSE Isocircle | | 2026-10-11 | minor | Object snap tracking, Extension, Parallel, Apparent Intersection, additional polar angles (#379): tracking 30% → 70%, object snaps 40% → 45%; total 43% → 48% | | 2026-10-10 | minor | Dynamic input 15% → 45% (pointer boxes, #60); press-drag windows (#63); total 40% → 43% | | 2026-10-10 | major | First UI-parity audit: point entry, snaps, tracking, dynamic input, grips, selection, command line, chrome, navigation, keys |