From 7e437cb62da5eeb4dd63c3531095e222ed5b51bb Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:24:46 +0200 Subject: [PATCH] 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 --- crates/engine/src/cmd/utility.rs | 312 ++++++------ crates/engine/src/cmd/utility/calc.rs | 598 +++++++++++++++++++++++ crates/engine/tests/quickcalc.rs | 98 ++++ crates/ui-egui/src/docking.rs | 12 +- crates/ui-egui/src/i18n/tests.rs | 1 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/palettes.rs | 3 + crates/ui-egui/src/palettes/quickcalc.rs | 356 ++++++++++++++ docs/control-protocol.md | 2 + 10 files changed, 1227 insertions(+), 172 deletions(-) create mode 100644 crates/engine/src/cmd/utility/calc.rs create mode 100644 crates/engine/tests/quickcalc.rs create mode 100644 crates/ui-egui/src/palettes/quickcalc.rs diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index f0e69934..89e4e00d 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -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 { 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::::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 { // ---------------- CAL ---------------- -struct Parser<'a> { - s: &'a [u8], - i: usize, - vars: &'a serde_json::Map, - 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 { - self.ws(); - self.s.get(self.i).copied() - } - fn expr(&mut self) -> std::result::Result { - 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 { - 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 { - 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 { - 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 { - 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::().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) -> std::result::Result { - 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 { + 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 { + 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, + pts: Vec, +} + +impl PickM { + fn finish(&self, s: &mut Session, mode: calc::Pick) -> Result { + 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 { + 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 { + 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 { + 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 ---------------- diff --git a/crates/engine/src/cmd/utility/calc.rs b/crates/engine/src/cmd/utility/calc.rs new file mode 100644 index 00000000..bb65f4f7 --- /dev/null +++ b/crates/engine/src/cmd/utility/calc.rs @@ -0,0 +1,598 @@ +//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d 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 { + match self { + Val::Num(v) => Ok(v), + Val::Pt(_) => Err(format!("{what} needs a number, not a point")), + } + } + fn pt(self, what: &str) -> Result { + 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, + depth: usize, +} + +impl Parser<'_> { + fn peek(&mut self) -> Option { + 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 { + 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 { + 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 { + 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 { + 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::().map_err(|_| format!("bad number `{t}`")) + } + fn atom(&mut self) -> Result { + 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, name: &str) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::, _>>()?; + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/engine/tests/quickcalc.rs b/crates/engine/tests/quickcalc.rs new file mode 100644 index 00000000..73ce566f --- /dev/null +++ b/crates/engine/tests/quickcalc.rs @@ -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()); +} diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index c7a64fd0..c44f8d1c 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -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::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), }; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 5261d4b0..82fc6e55 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -93,6 +93,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { 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"), diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 778b7ceb..015e0683 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f4e14a2a..7fd0d1c1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, + /// The last result: a number or `[x, y]`, the `ans` variable. + ans: Option, + error: Option, + /// 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)>>, + 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, +} + +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::>().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 { + 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 { + 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)); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b893a72b..0d11533d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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: