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:
parent
d2fda54e8a
commit
c4cc40355a
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.") {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue