feat(drafting): isometric snap/grid, ISODRAFT, ISOPLANE, ELLIPSE Isocircle (#561)

Squash of PR #561 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 19:13:02 +02:00 committed by GitHub
parent d2fda54e8a
commit c4cc40355a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 849 additions and 19 deletions

View File

@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
}
fn run_ellipse(s: &mut Session, p: &Value) -> Result<Value> {
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<f64>,
/// The Isocircle option (isometric snap style) in progress.
iso: Option<super::settings::iso::IsoCircle>,
}
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<EntityKind> {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
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();
}

View File

@ -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<Value> {
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<CommandSpec> {
.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)

View File

@ -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!("<Isodraft {}>", st.isoplane.name()) } else { "<Isodraft off>".to_string() },
})
}
/// The `plane` parameter: a plane, `orthographic` (`None` inside), or absent.
fn plane_param(cmd: &str, p: &Value) -> Result<Option<Option<IsoPlane>>> {
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<Value> {
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<Value> {
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!("<Isoplane {}>", 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<Step> {
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<Step> {
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<Value> {
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<Vec2>,
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<Option<Ellipse>> {
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<Ellipse> {
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 })
}
}

View File

@ -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 };

View File

@ -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};

View File

@ -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> {
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<IsoPlane> {
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<f64> {
(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<f64> {
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<Ellipse> {
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<GridLine> {
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());
}
}

View File

@ -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 }
}

View File

@ -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<Value> {
"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,

View File

@ -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<Value> {
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>) -> 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"], "<Isodraft Top>");
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<String> = (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("<Isodraft Right>")));
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;
}
}

View File

@ -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);
}

View File

@ -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));

View File

@ -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 Повторити

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -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.") {

View File

@ -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 |