mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #569 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
18b4a8d0dd
commit
7e437cb62d
10 files changed
+1227
-172
No files matched your search
+142
-170
@@ -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 ----------------
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user