feat(ui): QuickCalc palette; CAL points, vector functions, unit conversion (part of #563) (#569)

Squash of PR #569 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:24:46 -04:00
1 parent 18b4a8d0dd
commit 7e437cb62d
10 files changed
+1227 -172

No files matched your search

+142 -170
View File
@@ -5,17 +5,26 @@ use cadcraft_geom::{Polyline, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{EngineError, Result, Session};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("cal", "QuickCalc", run_cal)
.menu(&["Tools", "Inquiry", "Quick"])
.alias(&["quickcalc", "qc"])
.params("{expr: \"(3+4)*2^2\", vars?: {x: 1}}")
.params(
"{expr: \"(3+4)*2^2\" | \"dist([0,0],[3,4])\", vars?: {x: 1, p: [x, y]}} → {value} | {point} | \
{convert: {type?: length|area|volume|angle, from, to, value}} → {value} | {units: true} → unit lists | \
{pick: point|distance|angle|intersection, points}",
)
.enabled(always)
.noundo()
.transparent(),
.transparent()
.interactive(|_| Ok(Box::new(CalM))),
CommandSpec::new("quickcalc.pick", "QuickCalc Pick", run_quickcalc_pick)
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
.noundo()
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
@@ -29,182 +38,145 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------------- CAL ----------------
struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a serde_json::Map<String, Value>,
depth: usize,
}
mod calc;
impl Parser<'_> {
fn ws(&mut self) {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
self.i += 1;
}
}
fn peek(&mut self) -> Option<u8> {
self.ws();
self.s.get(self.i).copied()
}
fn expr(&mut self) -> std::result::Result<f64, String> {
self.depth += 1;
if self.depth > 200 {
return Err("expression too deeply nested".into());
}
let mut v = self.term()?;
while let Some(c) = self.peek() {
match c {
b'+' => {
self.i += 1;
v += self.term()?;
}
b'-' => {
self.i += 1;
v -= self.term()?;
}
_ => break,
}
}
self.depth -= 1;
Ok(v)
}
fn term(&mut self) -> std::result::Result<f64, String> {
let mut v = self.unary()?;
while let Some(c) = self.peek() {
match c {
b'*' => {
self.i += 1;
v *= self.unary()?;
}
b'/' => {
self.i += 1;
let d = self.unary()?;
if d == 0.0 {
return Err("division by zero".into());
}
v /= d;
}
_ => break,
}
}
Ok(v)
}
fn power(&mut self) -> std::result::Result<f64, String> {
let b = self.atom()?;
if self.peek() == Some(b'^') {
self.i += 1;
let e = self.unary()?;
return Ok(b.powf(e));
}
Ok(b)
}
fn unary(&mut self) -> std::result::Result<f64, String> {
match self.peek() {
Some(b'-') => {
self.i += 1;
Ok(-self.unary()?)
}
Some(b'+') => {
self.i += 1;
self.unary()
}
_ => self.power(),
}
}
fn atom(&mut self) -> std::result::Result<f64, String> {
match self.peek() {
Some(b'(') => {
self.i += 1;
let v = self.expr()?;
if self.peek() != Some(b')') {
return Err("missing )".into());
}
self.i += 1;
Ok(v)
}
Some(c) if c.is_ascii_digit() || c == b'.' => {
let st = self.i;
while self.s.get(self.i).is_some_and(|c| c.is_ascii_digit() || *c == b'.' || *c == b'e' || *c == b'E') {
self.i += 1;
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
t.parse::<f64>().map_err(|_| format!("bad number `{t}`"))
}
Some(c) if c.is_ascii_alphabetic() => {
let st = self.i;
while self.s.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
self.i += 1;
}
let name = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("").to_ascii_lowercase();
if self.peek() == Some(b'(') {
self.i += 1;
let a = self.expr()?;
let b = if self.peek() == Some(b',') {
self.i += 1;
Some(self.expr()?)
} else {
None
};
if self.peek() != Some(b')') {
return Err("missing )".into());
}
self.i += 1;
return Ok(match (name.as_str(), b) {
("sin", _) => a.to_radians().sin(),
("cos", _) => a.to_radians().cos(),
("tan", _) => a.to_radians().tan(),
("asin", _) => a.asin().to_degrees(),
("acos", _) => a.acos().to_degrees(),
("atan", None) => a.atan().to_degrees(),
("atan", Some(x)) => a.atan2(x).to_degrees(),
("sqrt", _) => a.sqrt(),
("sqr", _) => a * a,
("abs", _) => a.abs(),
("ln", _) => a.ln(),
("log", _) => a.log10(),
("exp", _) => a.exp(),
("round", _) => a.round(),
("trunc", _) => a.trunc(),
("r2d", _) => a.to_degrees(),
("d2r", _) => a.to_radians(),
("min", Some(x)) => a.min(x),
("max", Some(x)) => a.max(x),
("pow", Some(x)) => a.powf(x),
_ => return Err(format!("unknown function `{name}`")),
});
}
match name.as_str() {
"pi" => Ok(std::f64::consts::PI),
"e" => Ok(std::f64::consts::E),
_ => self.vars.get(&name).and_then(Value::as_f64).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
}
}
}
/// What QuickCalc's "get from the drawing" picks put on the command line before the value (the
/// palette reads it back from there).
const PICKED: &str = "QuickCalc: ";
/// Evaluate an arithmetic expression (degrees for trig, `^` power, functions, pi).
/// Evaluate an arithmetic expression to a number (degrees for trig, `^` power, functions, pi).
/// Expressions with points are evaluated by [`calc::eval_val`].
pub fn eval(expr: &str, vars: &serde_json::Map<String, Value>) -> std::result::Result<f64, String> {
if expr.len() > 10_000 {
return Err("expression too long".into());
match calc::eval_val(expr, vars)? {
calc::Val::Num(v) => Ok(v),
calc::Val::Pt(_) => Err("the result is a point, not a number".into()),
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
}
if !v.is_finite() {
return Err("result is not a finite number".into());
}
Ok(v)
}
fn run_cal(_s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "units", false) {
return Ok(json!({ "units": calc::units_json() }));
}
if let Some(c) = p.get("convert").filter(|v| !v.is_null()) {
let text = |k: &str| c.get(k).and_then(Value::as_str).ok_or_else(|| bad("cal", format!("`convert.{k}` is required")));
let vars = p.get("vars").and_then(Value::as_object).cloned().unwrap_or_default();
let v = match c.get("value") {
Some(Value::String(e)) => eval(e, &vars).map_err(|m| bad("cal", m))?,
Some(v) => v.as_f64().ok_or_else(|| bad("cal", "`convert.value` must be a number or an expression"))?,
None => return Err(bad("cal", "`convert.value` is required")),
};
let (from, to) = (text("from")?, text("to")?);
let r = calc::convert(c.get("type").and_then(Value::as_str), from, to, v).map_err(|m| bad("cal", m))?;
return Ok(json!({ "value": r, "message": format!("{} {from} = {} {to}", calc::num_text(v), calc::num_text(r)) }));
}
if let Some(m) = p.get("pick").filter(|v| !v.is_null()) {
let mode = m.as_str().and_then(calc::Pick::parse).ok_or_else(|| bad("cal", "`pick` must be point, distance, angle or intersection"))?;
let pts = points_param(p, "points").ok_or_else(|| bad("cal", "`points` must be a list of [x, y] points"))?;
let v = mode.value(&pts).map_err(|m| bad("cal", m))?;
let mut r = v.json();
r["message"] = json!(v.text());
return Ok(r);
}
let e = str_param(p, "expr").ok_or_else(|| bad("cal", "`expr` is required"))?;
let vars = p.get("vars").and_then(Value::as_object).cloned().unwrap_or_default();
let v = eval(e, &vars).map_err(|m| bad("cal", m))?;
Ok(json!({ "value": v, "message": format!("{v}") }))
let v = calc::eval_val(e, &vars).map_err(|m| bad("cal", m))?;
let mut r = v.json();
r["message"] = json!(v.text());
Ok(r)
}
/// CAL at the command line: one expression, its value echoed.
struct CalM;
impl Interactive for CalM {
fn name(&self) -> &'static str {
"CAL"
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new(">> Expression", Accept::TEXT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Input::Text(t) = i else { return Ok(Step::Done) };
if t.trim().is_empty() {
return Ok(Step::Done);
}
match calc::eval_val(&t, &serde_json::Map::new()) {
Ok(v) => s.echo(v.text()),
Err(m) => s.echo(format!("CAL: {m}")),
}
Ok(Step::Done)
}
}
/// QuickCalc's "get from the drawing": pick the points for a coordinate, distance, angle or
/// intersection; the value is echoed after [`PICKED`].
#[derive(Default)]
struct PickM {
mode: Option<calc::Pick>,
pts: Vec<Vec2>,
}
impl PickM {
fn finish(&self, s: &mut Session, mode: calc::Pick) -> Result<Step> {
match mode.value(&self.pts) {
Ok(v) => s.echo(format!("{PICKED}{}", v.text())),
Err(m) => s.echo(format!("QuickCalc: {m}")),
}
Ok(Step::Done)
}
}
impl Interactive for PickM {
fn name(&self) -> &'static str {
"QUICKCALC"
}
fn prompt(&self, _s: &Session) -> Prompt {
let msg = match (self.mode, self.pts.len()) {
(None, _) => {
return Prompt::new("Get from the drawing or [Point/Distance/Angle/Intersection]", Accept::POINT).kw(&[
"Point",
"Distance",
"Angle",
"Intersection",
]);
}
(Some(calc::Pick::Point), _) => "Specify point",
(Some(calc::Pick::Intersection), 0) => "Specify first point of first line",
(Some(calc::Pick::Intersection), 1) => "Specify second point of first line",
(Some(calc::Pick::Intersection), 2) => "Specify first point of second line",
(Some(calc::Pick::Intersection), _) => "Specify second point of second line",
(Some(_), 0) => "Specify first point",
(Some(_), _) => "Specify second point",
};
let base = if self.pts.len() % 2 == 1 { self.pts.last().copied() } else { None };
Prompt::new(msg, Accept::POINT).base_opt(base)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Keyword(k) if self.mode.is_none() => {
self.mode = calc::Pick::parse(&k);
Ok(Step::Continue)
}
Input::Point(p) => {
let mode = *self.mode.get_or_insert(calc::Pick::Point);
self.pts.push(p);
if self.pts.len() >= mode.points() { self.finish(s, mode) } else { Ok(Step::Continue) }
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.pts.last() {
Some(a) if self.pts.len() % 2 == 1 => vec![super::helpers::line(*a, c)],
_ => Vec::new(),
}
}
}
fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("point");
run_cal(s, &json!({ "pick": mode, "points": p.get("points").cloned().unwrap_or(Value::Null) }))
}
// ---------------- FIND ----------------
+598
View File
@@ -0,0 +1,598 @@
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
use cadcraft_geom::Vec2;
use serde_json::{Map, Value, json};
/// Longest expression accepted.
const MAX_LEN: usize = 10_000;
/// Deepest nesting of parentheses, brackets and function calls.
const MAX_DEPTH: usize = 200;
/// Most arguments a function takes.
const MAX_ARGS: usize = 8;
/// A CAL value: a number or a point (vector).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Val {
Num(f64),
Pt(Vec2),
}
impl Val {
pub fn is_finite(self) -> bool {
match self {
Val::Num(v) => v.is_finite(),
Val::Pt(p) => p.is_finite(),
}
}
/// The value as the command line shows it: a number, or `x,y`.
pub fn text(self) -> String {
match self {
Val::Num(v) => num_text(v),
Val::Pt(p) => format!("{},{}", num_text(p.x), num_text(p.y)),
}
}
/// `{value: n}` or `{point: [x, y]}`.
pub fn json(self) -> Value {
match self {
Val::Num(v) => json!({ "value": v }),
Val::Pt(p) => json!({ "point": [p.x, p.y] }),
}
}
fn num(self, what: &str) -> Result<f64, String> {
match self {
Val::Num(v) => Ok(v),
Val::Pt(_) => Err(format!("{what} needs a number, not a point")),
}
}
fn pt(self, what: &str) -> Result<Vec2, String> {
match self {
Val::Pt(p) => Ok(p),
Val::Num(_) => Err(format!("{what} needs a point, not a number")),
}
}
}
/// A number with at most ten decimals and no trailing zeros.
pub fn num_text(v: f64) -> String {
if v != 0.0 && (v.abs() >= 1e15 || v.abs() < 1e-6) {
return format!("{v:e}");
}
let s = format!("{v:.10}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a Map<String, Value>,
depth: usize,
}
impl Parser<'_> {
fn peek(&mut self) -> Option<u8> {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
self.i += 1;
}
self.s.get(self.i).copied()
}
fn eat(&mut self, c: u8) -> bool {
let hit = self.peek() == Some(c);
if hit {
self.i += 1;
}
hit
}
fn expect(&mut self, c: u8) -> Result<(), String> {
if self.eat(c) { Ok(()) } else { Err(format!("missing {}", c as char)) }
}
fn expr(&mut self) -> Result<Val, String> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err("expression too deeply nested".into());
}
let mut v = self.term()?;
loop {
if self.eat(b'+') {
v = add(v, self.term()?, 1.0)?;
} else if self.eat(b'-') {
v = add(v, self.term()?, -1.0)?;
} else {
break;
}
}
self.depth -= 1;
Ok(v)
}
fn term(&mut self) -> Result<Val, String> {
let mut v = self.unary()?;
loop {
if self.eat(b'*') {
v = mul(v, self.unary()?)?;
} else if self.eat(b'/') {
v = div(v, self.unary()?)?;
} else if self.eat(b'&') {
let (a, b) = (v.pt("&")?, self.unary()?.pt("&")?);
v = Val::Num(a.cross(b));
} else {
break;
}
}
Ok(v)
}
fn unary(&mut self) -> Result<Val, String> {
if self.eat(b'-') {
return Ok(match self.unary()? {
Val::Num(v) => Val::Num(-v),
Val::Pt(p) => Val::Pt(Vec2::ZERO - p),
});
}
if self.eat(b'+') {
return self.unary();
}
let b = self.atom()?;
if self.eat(b'^') {
let e = self.unary()?.num("^")?;
return Ok(Val::Num(b.num("^")?.powf(e)));
}
Ok(b)
}
fn number(&mut self) -> Result<f64, String> {
let st = self.i;
let digits = |p: &Self, i: usize| p.s.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.');
while digits(self, self.i) {
self.i += 1;
}
// An exponent: `e`/`E`, an optional sign, digits.
if matches!(self.s.get(self.i), Some(b'e' | b'E')) {
let mut j = self.i + 1;
if matches!(self.s.get(j), Some(b'+' | b'-')) {
j += 1;
}
if self.s.get(j).is_some_and(u8::is_ascii_digit) {
self.i = j;
while self.s.get(self.i).is_some_and(u8::is_ascii_digit) {
self.i += 1;
}
}
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
t.parse::<f64>().map_err(|_| format!("bad number `{t}`"))
}
fn atom(&mut self) -> Result<Val, String> {
match self.peek() {
Some(b'(') => {
self.i += 1;
let v = self.expr()?;
self.expect(b')')?;
Ok(v)
}
Some(b'[') => {
self.i += 1;
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err("expression too deeply nested".into());
}
let a = self.expr()?.num("a point coordinate")?;
let p = if self.eat(b'<') {
let ang = self.expr()?.num("a polar angle")?.to_radians();
Vec2::new(a * ang.cos(), a * ang.sin())
} else {
self.expect(b',')?;
let b = self.expr()?.num("a point coordinate")?;
// A Z coordinate is accepted and ignored (the drawing is 2D).
if self.eat(b',') {
self.expr()?.num("a point coordinate")?;
}
Vec2::new(a, b)
};
self.expect(b']')?;
self.depth -= 1;
Ok(Val::Pt(p))
}
Some(c) if c.is_ascii_digit() || c == b'.' => self.number().map(Val::Num),
Some(c) if c.is_ascii_alphabetic() || c == b'_' => {
let st = self.i;
while self.s.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
self.i += 1;
}
let name = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("").to_ascii_lowercase();
if self.eat(b'(') {
let mut args = Vec::new();
if !self.eat(b')') {
loop {
if args.len() >= MAX_ARGS {
return Err(format!("too many arguments to `{name}`"));
}
args.push(self.expr()?);
if self.eat(b')') {
break;
}
self.expect(b',')?;
}
}
return call(&name, &args);
}
match name.as_str() {
"pi" => Ok(Val::Num(std::f64::consts::PI)),
"e" => Ok(Val::Num(std::f64::consts::E)),
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
}
}
}
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
if let Some(n) = v.as_f64() {
return Some(Val::Num(n));
}
let a = v.as_array()?;
let (x, y) = (a.first()?.as_f64()?, a.get(1)?.as_f64()?);
Some(Val::Pt(Vec2::new(x, y)))
}
fn add(a: Val, b: Val, sign: f64) -> Result<Val, String> {
match (a, b) {
(Val::Num(a), Val::Num(b)) => Ok(Val::Num(a + sign * b)),
(Val::Pt(a), Val::Pt(b)) => Ok(Val::Pt(a + b * sign)),
_ => Err("can't add or subtract a number and a point".into()),
}
}
fn mul(a: Val, b: Val) -> Result<Val, String> {
Ok(match (a, b) {
(Val::Num(a), Val::Num(b)) => Val::Num(a * b),
(Val::Num(k), Val::Pt(p)) | (Val::Pt(p), Val::Num(k)) => Val::Pt(p * k),
// Two vectors: the scalar (dot) product.
(Val::Pt(a), Val::Pt(b)) => Val::Num(a.dot(b)),
})
}
fn div(a: Val, b: Val) -> Result<Val, String> {
let d = b.num("/")?;
if d == 0.0 {
return Err("division by zero".into());
}
Ok(match a {
Val::Num(a) => Val::Num(a / d),
Val::Pt(p) => Val::Pt(p * (1.0 / d)),
})
}
/// Angle of `v` in degrees, 0..360.
fn deg(v: Vec2) -> f64 {
v.y.atan2(v.x).to_degrees().rem_euclid(360.0)
}
/// Where the lines through `a`,`b` and `c`,`d` cross.
fn intersection(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Result<Vec2, String> {
let (r, s) = (b - a, d - c);
let den = r.cross(s);
if den.abs() <= 1e-12 * r.len().max(1e-300) * s.len().max(1e-300) {
return Err("the lines are parallel".into());
}
Ok(a + r * ((c - a).cross(s) / den))
}
fn call(name: &str, args: &[Val]) -> Result<Val, String> {
let n = |i: usize| args.get(i).copied().ok_or_else(|| format!("`{name}` needs more arguments")).and_then(|v| v.num(name));
let p = |i: usize| args.get(i).copied().ok_or_else(|| format!("`{name}` needs more arguments")).and_then(|v| v.pt(name));
let arity = |lo: usize, hi: usize| {
if (lo..=hi).contains(&args.len()) {
Ok(())
} else {
Err(format!("`{name}` takes {lo}{} argument(s)", if hi > lo { format!("–{hi}") } else { String::new() }))
}
};
let num1 = |f: fn(f64) -> f64| -> Result<Val, String> {
arity(1, 1)?;
Ok(Val::Num(f(n(0)?)))
};
match name {
"sin" => num1(|a| a.to_radians().sin()),
"cos" => num1(|a| a.to_radians().cos()),
"tan" => num1(|a| a.to_radians().tan()),
"asin" => num1(|a| a.asin().to_degrees()),
"acos" => num1(|a| a.acos().to_degrees()),
"atan" => {
arity(1, 2)?;
Ok(Val::Num(if args.len() == 2 { n(0)?.atan2(n(1)?) } else { n(0)?.atan() }.to_degrees()))
}
"sqrt" => num1(f64::sqrt),
"sqr" => num1(|a| a * a),
"ln" => num1(f64::ln),
"log" => num1(f64::log10),
"exp" => num1(f64::exp),
"exp10" => num1(|a| 10f64.powf(a)),
"round" => num1(f64::round),
"trunc" => num1(f64::trunc),
"r2d" => num1(f64::to_degrees),
"d2r" => num1(f64::to_radians),
"pow" => {
arity(2, 2)?;
Ok(Val::Num(n(0)?.powf(n(1)?)))
}
"min" | "max" => {
arity(1, MAX_ARGS)?;
let vs = (0..args.len()).map(n).collect::<Result<Vec<_>, _>>()?;
let f = if name == "min" { f64::min } else { f64::max };
Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0)))
}
// The absolute value of a number; the length of a vector.
"abs" => {
arity(1, 1)?;
Ok(Val::Num(match args.first() {
Some(Val::Pt(v)) => v.len(),
_ => n(0)?.abs(),
}))
}
"dist" => {
arity(2, 2)?;
Ok(Val::Num(p(0)?.dist(p(1)?)))
}
// ang(v): the vector's angle; ang(p1, p2): the line's; ang(apex, p1, p2): the angle at apex.
"ang" => {
arity(1, 3)?;
Ok(Val::Num(match args.len() {
1 => deg(p(0)?),
2 => deg(p(1)? - p(0)?),
_ => {
let a = deg(p(2)? - p(0)?) - deg(p(1)? - p(0)?);
a.rem_euclid(360.0)
}
}))
}
"vec" => {
arity(2, 2)?;
Ok(Val::Pt(p(1)? - p(0)?))
}
"vec1" => {
arity(2, 2)?;
let v = p(1)? - p(0)?;
if v.len() == 0.0 {
return Err("vec1 of two equal points".into());
}
Ok(Val::Pt(v * (1.0 / v.len())))
}
"ill" => {
arity(4, 4)?;
intersection(p(0)?, p(1)?, p(2)?, p(3)?).map(Val::Pt)
}
// The point at parameter t (0 at p1, 1 at p2) / at distance d from p1 towards p2.
"plt" => {
arity(3, 3)?;
let (a, b) = (p(0)?, p(1)?);
Ok(Val::Pt(a + (b - a) * n(2)?))
}
"pld" => {
arity(3, 3)?;
let (a, b) = (p(0)?, p(1)?);
let l = a.dist(b);
if l == 0.0 {
return Err("pld of two equal points".into());
}
Ok(Val::Pt(a + (b - a) * (n(2)? / l)))
}
// rot(p, origin, angle): p rotated about origin.
"rot" => {
arity(3, 3)?;
let (q, o, a) = (p(0)?, p(1)?, n(2)?.to_radians());
let d = q - o;
Ok(Val::Pt(o + Vec2::new(d.x * a.cos() - d.y * a.sin(), d.x * a.sin() + d.y * a.cos())))
}
_ => Err(format!("unknown function `{name}`")),
}
}
/// Evaluate an expression: numbers, points `[x,y]` / `[d<a]`, `+ - * / ^ &`, functions, `pi`, `e`
/// and the given variables (numbers or `[x, y]`). Degrees for trigonometry.
pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
if expr.len() > MAX_LEN {
return Err("expression too long".into());
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
}
if !v.is_finite() {
return Err("result is not a finite number".into());
}
Ok(v)
}
// ---------------- units ----------------
/// Unit tables: (kind, [(id, symbol, size in the base unit)]). Base units: metre, square metre,
/// cubic metre, radian.
const UNITS: &[(&str, &[(&str, &str, f64)])] = &[
(
"length",
&[
("um", "µm", 1e-6),
("mm", "mm", 1e-3),
("cm", "cm", 1e-2),
("dm", "dm", 0.1),
("m", "m", 1.0),
("km", "km", 1e3),
("in", "in", 0.0254),
("ft", "ft", 0.3048),
("yd", "yd", 0.9144),
("mi", "mi", 1609.344),
("nmi", "nmi", 1852.0),
],
),
(
"area",
&[
("mm2", "mm²", 1e-6),
("cm2", "cm²", 1e-4),
("m2", "m²", 1.0),
("ha", "ha", 1e4),
("km2", "km²", 1e6),
("in2", "in²", 0.00064516),
("ft2", "ft²", 0.09290304),
("yd2", "yd²", 0.83612736),
("acre", "acre", 4046.8564224),
("mi2", "mi²", 2589988.110336),
],
),
(
"volume",
&[
("mm3", "mm³", 1e-9),
("cm3", "cm³", 1e-6),
("ml", "ml", 1e-6),
("l", "l", 1e-3),
("m3", "m³", 1.0),
("in3", "in³", 1.6387064e-5),
("ft3", "ft³", 0.028316846592),
("yd3", "yd³", 0.764554857984),
("galus", "gal (US)", 3.785411784e-3),
("galuk", "gal (UK)", 4.54609e-3),
],
),
(
"angle",
&[
("deg", "°", std::f64::consts::PI / 180.0),
("rad", "rad", 1.0),
("grad", "grad", std::f64::consts::PI / 200.0),
("rev", "rev", std::f64::consts::TAU),
],
),
];
/// The unit tables as JSON: `{length: [{id, symbol}], area: …, volume: …, angle: …}`.
pub fn units_json() -> Value {
let mut m = Map::new();
for (kind, units) in UNITS {
m.insert((*kind).into(), units.iter().map(|(id, sym, _)| json!({ "id": id, "symbol": sym })).collect());
}
Value::Object(m)
}
/// Convert `v` between two units of one kind (ids or symbols, case-insensitive). With no kind,
/// the kind holding both units.
pub fn convert(kind: Option<&str>, from: &str, to: &str, v: f64) -> Result<f64, String> {
let find = |units: &[(&str, &str, f64)], u: &str| {
let u = u.trim();
units.iter().find(|(id, sym, _)| id.eq_ignore_ascii_case(u) || *sym == u).map(|(_, _, f)| *f)
};
for (k, units) in UNITS {
if kind.is_some_and(|kind| !kind.eq_ignore_ascii_case(k)) {
continue;
}
if let (Some(a), Some(b)) = (find(units, from), find(units, to)) {
let r = significant(v * a / b);
return if r.is_finite() { Ok(r) } else { Err("result is not a finite number".into()) };
}
}
match kind {
Some(k) if !UNITS.iter().any(|(n, _)| n.eq_ignore_ascii_case(k)) => {
Err(format!("unit type must be length, area, volume or angle, got `{k}`"))
}
_ => Err(format!("can't convert `{from}` to `{to}`")),
}
}
/// `v` to 14 significant digits: unit factors are exact decimals, so this drops the binary
/// rounding noise of the division (2 ft is 24 in, not 24.000000000000004).
fn significant(v: f64) -> f64 {
if v == 0.0 || !v.is_finite() {
return v;
}
let k = 13 - v.abs().log10().floor() as i32;
if !(-300..=300).contains(&k) {
return v;
}
let m = 10f64.powi(k);
let r = (v * m).round() / m;
if r.is_finite() { r } else { v }
}
// ---------------- values from the drawing ----------------
/// What QuickCalc reads from picked points.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// One point: its coordinates.
Point,
/// Two points: the distance between them.
Distance,
/// Two points: the angle of the line through them.
Angle,
/// Four points: where the two lines through them cross.
Intersection,
}
impl Pick {
pub fn parse(s: &str) -> Option<Pick> {
match s.trim().to_ascii_lowercase().as_str() {
"point" | "p" | "coordinates" => Some(Pick::Point),
"distance" | "d" => Some(Pick::Distance),
"angle" | "a" => Some(Pick::Angle),
"intersection" | "i" => Some(Pick::Intersection),
_ => None,
}
}
pub fn points(self) -> usize {
match self {
Pick::Point => 1,
Pick::Distance | Pick::Angle => 2,
Pick::Intersection => 4,
}
}
/// The value read from exactly [`Pick::points`] points.
pub fn value(self, pts: &[Vec2]) -> Result<Val, String> {
let p = |i: usize| pts.get(i).copied().filter(|p| p.is_finite()).ok_or_else(|| format!("{} point(s) are needed", self.points()));
if pts.len() != self.points() {
return Err(format!("{} point(s) are needed", self.points()));
}
Ok(match self {
Pick::Point => Val::Pt(p(0)?),
Pick::Distance => Val::Num(p(0)?.dist(p(1)?)),
Pick::Angle => Val::Num(deg(p(1)? - p(0)?)),
Pick::Intersection => Val::Pt(intersection(p(0)?, p(1)?, p(2)?, p(3)?)?),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(s: &str) -> Result<Val, String> {
eval_val(s, &Map::new())
}
#[test]
fn points_and_vectors() {
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
assert_eq!(ev("dist([0,0],[3,4])"), Ok(Val::Num(5.0)));
assert_eq!(ev("[1,0]*[0,1]"), Ok(Val::Num(0.0)));
assert_eq!(ev("[1,0]&[0,1]"), Ok(Val::Num(1.0)));
assert_eq!(ev("ill([0,0],[2,2],[0,2],[2,0])"), Ok(Val::Pt(Vec2::new(1.0, 1.0))));
assert!(ev("ill([0,0],[1,0],[0,1],[1,1])").is_err());
assert!((ev("ang([0,0],[1,1])").unwrap().num("").unwrap() - 45.0).abs() < 1e-12);
assert!(ev("[1,2]+3").is_err());
assert!(ev("[1").is_err());
assert!(ev(&"[".repeat(5000)).is_err());
assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0)));
assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002)));
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
}
#[test]
fn number_text() {
assert_eq!(num_text(0.1 + 0.2), "0.3");
assert_eq!(num_text(28.0), "28");
assert_eq!(num_text(-0.0), "0");
assert_eq!(Val::Pt(Vec2::new(1.5, -2.0)).text(), "1.5,-2");
}
}
+98
View File
@@ -0,0 +1,98 @@
//! CAL and the engine side of the QuickCalc palette: points and vector functions, unit
//! conversion, values picked from the drawing (#563).
use cadcraft_engine::Session;
use serde_json::json;
#[test]
fn cal_evaluates_points_and_vector_functions() {
let mut s = Session::new();
let r = s.execute("cal", &json!({"expr": "(3+4)*2^2"})).unwrap();
assert_eq!((r["value"].as_f64(), r["message"].as_str()), (Some(28.0), Some("28")));
let r = s.execute("cal", &json!({"expr": "plt([0,0],[10,0],0.25)+[0,1]"})).unwrap();
assert_eq!(r["point"], json!([2.5, 1.0]), "{r}");
assert_eq!(r["message"], "2.5,1");
let r = s.execute("cal", &json!({"expr": "dist(p, [3,4]) + k", "vars": {"P": [0, 0], "k": 1}})).unwrap();
assert_eq!(r["value"], 6.0, "variables are case-insensitive and may be points: {r}");
let r = s.execute("cal", &json!({"expr": "ang([0,0],[0,5])"})).unwrap();
assert_eq!(r["value"], 90.0);
let r = s.execute("cal", &json!({"expr": "[10<90]"})).unwrap();
assert!(r["point"][0].as_f64().unwrap().abs() < 1e-9 && (r["point"][1].as_f64().unwrap() - 10.0).abs() < 1e-9, "{r}");
for bad in ["[1,2]+1", "dist([0,0])", "1/0", "sqrt(-1)", "x", "ill([0,0],[1,0],[0,1],[1,1])"] {
assert!(s.execute("cal", &json!({"expr": bad})).is_err(), "{bad}");
}
}
#[test]
fn cal_converts_units() {
let mut s = Session::new();
let conv = |s: &mut Session, t: &str, from: &str, to: &str, v: serde_json::Value| {
s.execute("cal", &json!({"convert": {"type": t, "from": from, "to": to, "value": v}})).map(|r| r["value"].as_f64().unwrap())
};
assert!((conv(&mut s, "length", "in", "mm", json!(1)).unwrap() - 25.4).abs() < 1e-9);
assert!((conv(&mut s, "area", "m2", "ft²", json!(1)).unwrap() - 10.763910416709722).abs() < 1e-9);
assert!((conv(&mut s, "volume", "l", "galus", json!("2*1.5")).unwrap() - 0.792516157).abs() < 1e-6);
assert!((conv(&mut s, "angle", "rad", "deg", json!("pi")).unwrap() - 180.0).abs() < 1e-9);
// The type may be left out when the units say it.
let r = s.execute("cal", &json!({"convert": {"from": "ft", "to": "in", "value": 2}})).unwrap();
assert_eq!((r["value"].as_f64(), r["message"].as_str()), (Some(24.0), Some("2 ft = 24 in")));
assert!(conv(&mut s, "length", "m", "m2", json!(1)).is_err(), "metres don't convert to square metres");
assert!(conv(&mut s, "mass", "kg", "lb", json!(1)).is_err());
assert!(conv(&mut s, "length", "m", "mm", json!(true)).is_err());
let r = s.execute("cal", &json!({"units": true})).unwrap();
for kind in ["length", "area", "volume", "angle"] {
assert!(r["units"][kind].as_array().is_some_and(|u| u.len() >= 4), "{kind}: {r}");
}
}
#[test]
fn picks_read_values_from_points() {
let mut s = Session::new();
let r = s.execute("cal", &json!({"pick": "distance", "points": [[0, 0], [3, 4]]})).unwrap();
assert_eq!(r["value"], 5.0);
let r = s.execute("quickcalc.pick", &json!({"mode": "intersection", "points": [[0, 0], [2, 2], [0, 2], [2, 0]]})).unwrap();
assert_eq!(r["point"], json!([1.0, 1.0]));
assert!(s.execute("quickcalc.pick", &json!({"mode": "angle", "points": [[0, 0]]})).is_err(), "two points are needed");
assert!(s.execute("quickcalc.pick", &json!({"mode": "nope", "points": []})).is_err());
}
#[test]
fn cal_and_picks_at_the_command_line() {
let mut s = Session::new();
s.cmdline("cal").unwrap();
s.cmdline("(3+4)*2").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("14"));
s.start("quickcalc.pick").unwrap();
s.cmdline("d").unwrap();
s.cmdline("0,0").unwrap();
s.cmdline("3,4").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("QuickCalc: 5"));
// A point at the first prompt picks coordinates.
s.start("quickcalc.pick").unwrap();
s.cmdline("2,7").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("QuickCalc: 2,7"));
s.start("quickcalc.pick").unwrap();
for t in ["i", "0,0", "4,4", "0,4", "4,0"] {
s.cmdline(t).unwrap();
}
assert_eq!(s.log.last().map(String::as_str), Some("QuickCalc: 2,2"));
assert!(s.running.is_none());
}
#[test]
fn hostile_expressions_never_panic() {
let mut s = Session::new();
for e in [
"[".repeat(10_000),
"(".repeat(300) + "1",
"max(1,2,3,4,5,6,7,8,9)".into(),
"1e999".into(),
"[1e308,1e308]*1e10".into(),
"ang()".into(),
"\u{0}".into(),
] {
assert!(s.execute("cal", &json!({"expr": e})).is_err(), "{e:.40}");
}
assert!(s.execute("cal", &json!({"expr": "x", "vars": {"x": [1]}})).is_err());
assert!(s.execute("cal", &json!({"pick": "point", "points": [[1e308, "a"]]})).is_err());
}
+10 -2
View File
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
pub enum Panel {
Canvas,
ToolSets,
QuickCalc,
Layers,
Properties,
#[serde(rename = "externalReferences")]
@@ -23,6 +24,7 @@ impl Panel {
match self {
Self::Canvas => "canvas",
Self::ToolSets => "toolSets",
Self::QuickCalc => "quickCalc",
Self::Layers => "layers",
Self::Properties => "properties",
Self::ExternalRefs => "externalReferences",
@@ -32,6 +34,7 @@ impl Panel {
match self {
Self::Canvas => "Drawing",
Self::ToolSets => "Tool Sets",
Self::QuickCalc => "QuickCalc",
Self::Layers => "Layers",
Self::Properties => "Properties",
Self::ExternalRefs => "External References",
@@ -41,13 +44,16 @@ impl Panel {
match self {
Self::Canvas => Zone::Right,
Self::ToolSets => Zone::Left,
Self::QuickCalc => Zone::Right,
Self::Layers => Zone::Right,
Self::Properties => Zone::Right,
Self::ExternalRefs => Zone::Right,
}
}
/// Palettes outside the default arrangement, opened on request (QUICKCALC).
pub const ON_DEMAND: &[Self] = &[Self::QuickCalc];
fn parse(id: &str) -> Result<Self, String> {
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
}
}
@@ -101,7 +107,7 @@ impl Default for Workspace {
impl Workspace {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate().map_err(|error| error.to_string())?;
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() {
return Err("The panel layout must contain its document view and bounded hidden panels".into());
}
// Saved settings cannot turn the protected document view into a floating or closable tab.
@@ -343,6 +349,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
crate::canvas::show(app, ui);
}
Panel::ToolSets => crate::palettes::toolsets(app, ui),
Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui),
Panel::Layers => {
egui::ScrollArea::vertical()
.id_salt("dock-layers")
@@ -384,6 +391,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
fn panel_limits(panel: &Panel) -> PanelLimits {
let (min, max_width) = match panel {
Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0),
Panel::QuickCalc => (egui::vec2(240.0, 160.0), 600.0),
Panel::ToolSets => (egui::vec2(220.0, 150.0), 480.0),
Panel::Layers | Panel::Properties | Panel::ExternalRefs => (egui::vec2(220.0, 100.0), 720.0),
};
+1
View File
@@ -93,6 +93,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
include_str!("../chrome.rs"),
include_str!("../cmdline.rs"),
include_str!("../credits.rs"),
include_str!("../palettes/quickcalc.rs"),
include_str!("../dialogs.rs"),
include_str!("../layers.rs"),
include_str!("../palettes.rs"),
+12
View File
@@ -50,6 +50,7 @@
<> Not Equal <> Не дорівнює
= Equals = Дорівнює
> Greater than > Більше ніж
A variable name starts with a letter and has only letters, digits and _ Ім'я змінної починається з літери й містить лише літери, цифри та _
About Про програму
About CADCraft Про CADCraft
Absolute Абсолютні
@@ -131,6 +132,7 @@
Change to Current Layer Перенести на поточний шар
Circle Коло
Circumference Довжина кола
Clear History Очистити журнал
Clear Очистити
Clip Обрізати
Clip Viewport Обрізати видовий екран
@@ -165,6 +167,7 @@
Continue Продовжити
Contributors Учасники
Control Vertices Керувальні вершини
Convert Перетворити
Copy Копіювати
Copy Full File Path Копіювати повний шлях до файлу
Copy History Копіювати історію
@@ -312,6 +315,7 @@
Entire drawing Усе креслення
Equal Рівність
Erase Стерти
Evaluate Обчислити
Exclude from new selection set Виключити з нового набору вибраних об'єктів
Explode Розчленувати
Export Експортувати
@@ -351,6 +355,7 @@
Geometric Constraint Геометричне обмеження
Geometric Constraints Геометричні обмеження
Geometry Геометрія
Get from the Drawing Взяти з креслення
Get Variable Отримати змінну
Get View Отримати вид
Global Глобально
@@ -388,6 +393,7 @@
Hide Layer List Приховати список шарів
Hide Objects Приховати об'єкти
Hide Palettes Приховати палітри
History Журнал
Home Початковий вид
Horizontal Горизонтальність
HorizontalDistance Горизонтальна відстань
@@ -600,6 +606,7 @@
Parametric Параметризація
Paste Вставити
Paste as Block Вставити як блок
Paste to Command Line Вставити в командний рядок
Paste to Original Coordinates Вставити у вихідні координати
Path Array Масив уздовж траєкторії
Pattern Візерунок
@@ -677,6 +684,7 @@
Quick Select Properties Властивості швидкого вибору
Quick Select... Швидкий вибір...
QuickCalc Калькулятор
QuickCalc Pick Вибір для калькулятора
Quit CADCraft Вийти з CADCraft
Radius Радіус
Raster Image Растрове зображення
@@ -1030,6 +1038,7 @@
Switch between model space in a viewport and paper space Перемкнути між простором моделі у видовому екрані та простором аркуша
Symmetric Симетрія
Synchronize Attributes Синхронізувати атрибути
To До
Toggle PICKADD (off: each pick replaces the selection) Перемкнути PICKADD (вимкнено: кожен вибір замінює набір вибраних об'єктів)
Toggle PICKADD (on: picks add to the selection) Перемкнути PICKADD (увімкнено: вибране додається до набору вибраних об'єктів)
TOP ЗВЕРХУ
@@ -1079,6 +1088,7 @@
Ungroup Розгрупувати
Union Об'єднання
Unisolate Layer Скасувати ізоляцію шарів
Units Conversion Перетворення одиниць
Units... Одиниці...
Unlock Layer Розблокувати шар
Unlock viewport Розблокувати видовий екран
@@ -1091,10 +1101,12 @@
User Користувач
User Parameters Параметри користувача
Username Ім'я користувача
Volume Об'єм
VP Color Колір ВЕ
VP Freeze Заморожування ВЕ
Value Значення
Value: Значення:
Variables Змінні
Version Версія
Version 2000 Версія 2000
Version 2004 Версія 2004
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
50
51
52
53
54
55
56
132
133
134
135
136
137
138
167
168
169
170
171
172
173
315
316
317
318
319
320
321
355
356
357
358
359
360
361
393
394
395
396
397
398
399
606
607
608
609
610
611
612
684
685
686
687
688
689
690
1038
1039
1040
1041
1042
1043
1044
1088
1089
1090
1091
1092
1093
1094
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
+5
View File
@@ -19,6 +19,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")),
("ui.panel.properties", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")),
("ui.panel.layers", "Layers Palette", &["Window", "Layers Palette"], None),
("ui.panel.quickcalc", "QuickCalc", &["Window", "QuickCalc"], Some("Cmd+8")),
("ui.toggle.systemcursor", "Show System Cursor", &["View", "Accessibility", "Show System Cursor"], None),
("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None),
("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None),
@@ -83,6 +84,10 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
let panel = if id == "ui.panel.properties" { "properties" } else { "layers" };
return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":panel})));
}
// QUICKCALC typed or from a menu opens the palette; with parameters it is CAL.
if id == "ui.panel.quickcalc" || (matches!(id, "quickcalc" | "qc") && params.is_null()) {
return Some(crate::palettes::quickcalc::open(app));
}
if let Some(r) = crate::plotstyles::route(app, id, params) {
return Some(r);
}
+3
View File
@@ -9,6 +9,9 @@ use crate::CadApp;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
/// The QuickCalc palette.
pub mod quickcalc;
type Tool = (Icon, &'static str, &'static str);
/// Tool Sets groups: (name, large tools, small tools).
+356
View File
@@ -0,0 +1,356 @@
//! The QuickCalc palette: a thin face on the engine's CAL command. Expressions, conversions and
//! variables are evaluated by `cal`; values are read from the drawing by `quickcalc.pick`, whose
//! point prompts run on the command line like any other command.
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::CadApp;
use crate::theme::Tokens;
/// What `quickcalc.pick` writes to the command history before the value it read.
const PICKED: &str = "QuickCalc: ";
/// History entries kept.
const MAX_HISTORY: usize = 100;
/// Variables kept.
const MAX_VARS: usize = 50;
const KINDS: [(&str, &str); 4] = [("length", "Length"), ("area", "Area"), ("volume", "Volume"), ("angle", "Angle")];
#[derive(Clone, Default)]
struct State {
input: String,
/// (expression, result) newest last.
history: Vec<(String, String)>,
/// The last result: a number or `[x, y]`, the `ans` variable.
ans: Option<Value>,
error: Option<String>,
/// Variables: name and value (a number or `[x, y]`).
vars: Vec<(String, Value)>,
var_name: String,
var_expr: String,
/// Unit tables from `cal {units: true}`: kind → [(id, symbol)].
units: Option<Vec<(String, Vec<(String, String)>)>>,
kind: usize,
from: usize,
to: usize,
conv_value: String,
conv_result: String,
/// The command history length when a pick started; the pick's value follows it.
pending: Option<usize>,
}
fn id() -> egui::Id {
egui::Id::new("cadcraft-quickcalc")
}
/// A variable's value as typed into an expression: a number, or a point as `[x,y]`.
fn var_text(v: &Value) -> String {
match v.as_array() {
Some(a) => format!("[{}]", a.iter().map(|c| c.as_f64().map(|c| c.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(",")),
None => v.as_f64().map(|n| n.to_string()).unwrap_or_default(),
}
}
/// `cal`'s value or point, as a variable value.
fn result_value(r: &Value) -> Option<Value> {
r.get("point").cloned().or_else(|| r.get("value").cloned()).filter(|v| !v.is_null())
}
impl State {
fn vars_json(&self) -> Value {
let mut m = serde_json::Map::new();
for (k, v) in &self.vars {
m.insert(k.clone(), v.clone());
}
if let Some(a) = &self.ans {
m.entry("ans").or_insert(a.clone());
}
Value::Object(m)
}
fn evaluate(&mut self, app: &mut CadApp) {
let expr = self.input.trim().to_string();
if expr.is_empty() {
return;
}
match app.run("cal", json!({ "expr": expr, "vars": self.vars_json() })) {
Ok(r) => {
let text = r.get("message").and_then(Value::as_str).unwrap_or_default().to_string();
self.ans = result_value(&r);
self.history.push((expr, text.clone()));
if self.history.len() > MAX_HISTORY {
self.history.remove(0);
}
self.input = text;
self.error = None;
}
Err(e) => self.error = Some(e),
}
}
fn add_var(&mut self, app: &mut CadApp) {
let name = self.var_name.trim().to_ascii_lowercase();
let valid = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !["pi", "e", "ans"].contains(&name.as_str());
if !valid {
self.error = Some(crate::tl!("A variable name starts with a letter and has only letters, digits and _").into());
return;
}
match app.run("cal", json!({ "expr": self.var_expr.trim(), "vars": self.vars_json() })) {
Ok(r) => {
if let Some(v) = result_value(&r) {
self.vars.retain(|(k, _)| *k != name);
if self.vars.len() < MAX_VARS {
self.vars.push((name, v));
}
self.var_name.clear();
self.var_expr.clear();
self.error = None;
}
}
Err(e) => self.error = Some(e),
}
}
fn units(&mut self, app: &mut CadApp) -> Vec<(String, Vec<(String, String)>)> {
if self.units.is_none() {
let r = app.run("cal", json!({ "units": true })).unwrap_or_default();
let tables = KINDS
.iter()
.map(|(kind, _)| {
let list = r["units"][*kind]
.as_array()
.map(|a| {
a.iter().filter_map(|u| Some((u.get("id")?.as_str()?.to_string(), u.get("symbol")?.as_str()?.to_string()))).collect()
})
.unwrap_or_default();
(kind.to_string(), list)
})
.collect();
self.units = Some(tables);
self.default_pair();
}
self.units.clone().unwrap_or_default()
}
/// A common pair of units for the chosen kind (millimetres to inches, …).
fn default_pair(&mut self) {
let (from, to) = match KINDS.get(self.kind).map(|k| k.0) {
Some("length") => ("mm", "in"),
Some("area") => ("m2", "ft2"),
Some("volume") => ("l", "galus"),
_ => ("deg", "rad"),
};
let list = self.units.as_ref().and_then(|t| t.get(self.kind)).map(|(_, l)| l.as_slice()).unwrap_or_default();
let at = |id: &str| list.iter().position(|(u, _)| u == id).unwrap_or(0);
(self.from, self.to) = (at(from), at(to));
}
fn convert(&mut self, app: &mut CadApp) {
let tables = self.units(app);
let Some((kind, list)) = tables.get(self.kind) else { return };
let (Some(from), Some(to)) = (list.get(self.from), list.get(self.to)) else { return };
let value = if self.conv_value.trim().is_empty() { "1".to_string() } else { self.conv_value.trim().to_string() };
let p = json!({ "convert": { "type": kind, "from": from.0, "to": to.0, "value": value }, "vars": self.vars_json() });
match app.run("cal", p) {
Ok(r) => {
self.conv_result = r.get("value").and_then(Value::as_f64).map(|v| v.to_string()).unwrap_or_default();
self.error = None;
}
Err(e) => self.error = Some(e),
}
}
/// Start reading a value from the drawing; the points are asked for on the command line.
fn pick(&mut self, app: &mut CadApp, mode: &str) {
self.pending = Some(app.session.log.len());
app.start("quickcalc.pick");
app.cmdline(mode);
}
/// When the pick has ended, put the value it read into the expression.
fn collect(&mut self, app: &CadApp) {
let Some(from) = self.pending else { return };
if app.session.running.is_some() {
return;
}
self.pending = None;
let log = &app.session.log;
// The history is trimmed from the front when it grows long: then look at its end only.
let from = if from <= log.len() { from } else { log.len().saturating_sub(20) };
let Some(v) = log.get(from..).unwrap_or_default().iter().rev().find_map(|l| l.strip_prefix(PICKED)) else { return };
let v = v.trim();
let text = if v.contains(',') { format!("[{v}]") } else { v.to_string() };
if !self.input.is_empty() && !self.input.ends_with([' ', '(', ',', '+', '-', '*', '/', '^', '[']) {
self.input.clear();
}
self.input.push_str(&text);
}
}
/// Open the palette. The first time it floats by the right edge of the drawing; afterwards it
/// returns where it was closed.
pub fn open(app: &mut CadApp) -> Result<Value, String> {
let panel = crate::docking::Panel::QuickCalc;
let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel);
let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?;
if !first {
return Ok(r);
}
let area = app.canvas.rect.unwrap_or(egui::Rect::from_min_size(egui::pos2(220.0, 80.0), vec2(1000.0, 800.0)));
let (w, h) = (300.0, (area.height() - 40.0).clamp(200.0, 560.0));
let rect = [area.right() - w - 16.0, area.top() + 24.0, w, h];
crate::docking::command(app, false, &json!({ "operation": "float", "panel": panel.id(), "rect": rect }))
}
pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default();
st.collect(app);
egui::ScrollArea::vertical().id_salt("dock-quickcalc").auto_shrink([false, false]).show(ui, |ui| {
ui.add_space(6.0);
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
// The expression and its buttons.
let w = ui.available_width() - 12.0;
ui.horizontal(|ui| {
ui.add_space(6.0);
let field = egui::TextEdit::singleline(&mut st.input).hint_text(crate::tl!("Expression")).font(crate::theme::mono()).desired_width(w);
let r = ui.add(field);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
st.evaluate(app);
r.request_focus();
}
});
if let Some(e) = &st.error {
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.label(RichText::new(e).color(t.text_dim).font(crate::theme::small()));
});
}
ui.horizontal_wrapped(|ui| {
ui.add_space(6.0);
if ui.button("=").on_hover_text(crate::tl!("Evaluate")).clicked() {
st.evaluate(app);
}
if ui.button(crate::tl!("Clear")).clicked() {
st.input.clear();
st.error = None;
}
let has_value = !st.input.trim().is_empty();
if ui.add_enabled(has_value, egui::Button::new(crate::tl!("Paste to Command Line"))).clicked() {
let v = st.input.trim().trim_start_matches('[').trim_end_matches(']').to_string();
app.cmdline(&v);
}
});
// Values read from the drawing.
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.label(RichText::new(crate::tl!("Get from the Drawing")).color(t.text_dim));
});
ui.horizontal_wrapped(|ui| {
ui.add_space(6.0);
for (mode, label) in [
("Point", crate::tl!("Point")),
("Distance", crate::tl!("Distance")),
("Angle", crate::tl!("Angle")),
("Intersection", crate::tl!("Intersection")),
] {
if ui.add_enabled(st.pending.is_none(), egui::Button::new(label)).clicked() {
st.pick(app, mode);
}
}
});
ui.separator();
// History: click an entry to reuse its expression, its result with the second button.
egui::CollapsingHeader::new(crate::tl!("History")).id_salt("qc-history").default_open(true).show(ui, |ui| {
let mut reuse = None;
for (expr, result) in st.history.iter().rev().take(20) {
ui.horizontal(|ui| {
if ui.add(egui::Label::new(RichText::new(expr).font(crate::theme::mono())).sense(egui::Sense::click())).clicked() {
reuse = Some(expr.clone());
}
ui.label(RichText::new("=").color(t.text_dim));
if ui
.add(egui::Label::new(RichText::new(result).font(crate::theme::mono()).color(t.accent)).sense(egui::Sense::click()))
.clicked()
{
reuse = Some(if result.contains(',') { format!("[{result}]") } else { result.clone() });
}
});
}
if let Some(e) = reuse {
st.input = e;
}
if !st.history.is_empty() && ui.small_button(crate::tl!("Clear History")).clicked() {
st.history.clear();
}
});
// Units conversion.
egui::CollapsingHeader::new(crate::tl!("Units Conversion")).id_salt("qc-units").default_open(true).show(ui, |ui| {
let tables = st.units(app);
egui::Grid::new("qc-units-grid").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
ui.label(crate::tl!("Type"));
let kind_label = KINDS.get(st.kind).map_or("", |k| k.1);
egui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| {
for (i, (_, label)) in KINDS.iter().enumerate() {
if ui.selectable_label(st.kind == i, crate::i18n::t(label)).clicked() {
st.kind = i;
st.default_pair();
st.conv_result.clear();
}
}
});
ui.end_row();
let list = tables.get(st.kind).map(|(_, l)| l.clone()).unwrap_or_default();
for (label, salt, which) in [(crate::tl!("From"), "qc-from", 0), (crate::tl!("To"), "qc-to", 1)] {
ui.label(label);
let sel = if which == 0 { &mut st.from } else { &mut st.to };
let shown = list.get(*sel).map(|u| u.1.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| {
for (i, (_, sym)) in list.iter().enumerate() {
if ui.selectable_label(*sel == i, sym).clicked() {
*sel = i;
}
}
});
ui.end_row();
}
ui.label(crate::tl!("Value"));
ui.add(egui::TextEdit::singleline(&mut st.conv_value).hint_text("1").desired_width(120.0));
ui.end_row();
if ui.button(crate::tl!("Convert")).clicked() {
st.convert(app);
}
if !st.conv_result.is_empty() {
let r = ui
.add(egui::Label::new(RichText::new(&st.conv_result).font(crate::theme::mono()).color(t.accent)).sense(egui::Sense::click()));
if r.clicked() {
st.input.push_str(&st.conv_result);
}
}
ui.end_row();
});
});
// Variables: click one to put its name into the expression.
egui::CollapsingHeader::new(crate::tl!("Variables")).id_salt("qc-vars").default_open(true).show(ui, |ui| {
let mut remove = None;
for (i, (name, v)) in st.vars.iter().enumerate() {
ui.horizontal(|ui| {
if ui.add(egui::Label::new(RichText::new(name).font(crate::theme::mono())).sense(egui::Sense::click())).clicked() {
st.input.push_str(name);
}
ui.label(RichText::new(format!("= {}", var_text(v))).font(crate::theme::mono()).color(t.text_dim));
if ui.small_button("×").on_hover_text(crate::tl!("Delete")).clicked() {
remove = Some(i);
}
});
}
if let Some(i) = remove {
st.vars.remove(i);
}
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut st.var_name).hint_text(crate::tl!("Name")).desired_width(60.0));
ui.label("=");
ui.add(egui::TextEdit::singleline(&mut st.var_expr).hint_text(crate::tl!("Expression")).desired_width(110.0));
if ui.button(crate::tl!("Add")).clicked() {
st.add_var(app);
}
});
});
});
ui.ctx().data_mut(|d| d.insert_temp(id(), st));
}
+2
View File
@@ -69,6 +69,8 @@ Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protect
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
The QuickCalc palette (`quickCalc`) is not part of the default arrangement: QUICKCALC (`ui.panel.quickcalc`, Cmd+8) opens it, floating the first time.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"layers","rect":[100,100,320,440]}}`.
Advanced `ui.dock` operations use the same validated transaction as pointer gestures: