forked from 1b-agent/opencadstudio-upstream
Point & line and 2Lines take a text baseline or an ellipse axis as the line; a zero value folds the points onto the first one; 2Lines turns the second line about its start. The Properties Constraint section edits Constraint Form (Dynamic on the *ADSK_CONSTRAINTS layer, Annotational on the current layer), Reference (driven constraint in parentheses whose parameter follows the measurement) and Description (stored with the parameter and the native variable). Dynamic dimensions keep a 12 px text and 8 px arrows at any zoom, drop trailing zeros and show formulas as `fx: len=d1*2`; the hidden constraints layer stays out of the layer lists. Co-Authored-By: Claude Fable 5.1 <[email protected]>
965 lines
33 KiB
Rust
965 lines
33 KiB
Rust
//! Named, formula-capable parameters stored per drawing.
|
|
//! Formulas are parsed locally so dependency cycles can be rejected before evaluation.
|
|
|
|
use super::Scene;
|
|
use std::collections::HashMap;
|
|
use std::fmt;
|
|
|
|
/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to
|
|
/// another parameter by name) and [`Expr::Call`] (a function call). Only the
|
|
/// small builtin set in `call_builtin` is accepted.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Expr {
|
|
Number(f64),
|
|
Ref(String),
|
|
Neg(Box<Expr>),
|
|
Add(Box<Expr>, Box<Expr>),
|
|
Sub(Box<Expr>, Box<Expr>),
|
|
Mul(Box<Expr>, Box<Expr>),
|
|
Div(Box<Expr>, Box<Expr>),
|
|
Pow(Box<Expr>, Box<Expr>),
|
|
Call(String, Vec<Expr>),
|
|
}
|
|
|
|
impl Expr {
|
|
/// Every parameter name this expression reads, deduplicated in
|
|
/// first-seen order. This is the entire basis for cycle detection
|
|
/// (`ParameterTable::set`): one dependency edge per name returned here.
|
|
fn refs(&self, out: &mut Vec<String>) {
|
|
match self {
|
|
Expr::Number(_) => {}
|
|
Expr::Ref(name) => {
|
|
if !out.contains(name) {
|
|
out.push(name.clone());
|
|
}
|
|
}
|
|
Expr::Neg(a) => a.refs(out),
|
|
Expr::Add(a, b)
|
|
| Expr::Sub(a, b)
|
|
| Expr::Mul(a, b)
|
|
| Expr::Div(a, b)
|
|
| Expr::Pow(a, b) => {
|
|
a.refs(out);
|
|
b.refs(out);
|
|
}
|
|
Expr::Call(_, args) => {
|
|
for arg in args {
|
|
arg.refs(out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything that can go wrong parsing, defining, or resolving a
|
|
/// parameter. Every case is reported back to the caller (and, in later
|
|
/// stages, the UI) rather than panicking — matching this codebase's
|
|
/// established pattern of isolating a bad derived value instead of letting
|
|
/// it take down an unrelated computation (e.g. dangling-constraint handling
|
|
/// in `parametric_solve.rs`).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ParamError {
|
|
/// A formula's text doesn't parse. The `String` is a short, human-
|
|
/// readable reason, not a machine-parseable code.
|
|
Parse(String),
|
|
/// Not a valid identifier, or collides with a builtin function name
|
|
/// (`is_reserved_name`).
|
|
InvalidName(String),
|
|
/// Accepting this formula would close a circular reference right now.
|
|
/// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`,
|
|
/// `b = a`.
|
|
Cycle(Vec<String>),
|
|
UnknownFunction(String),
|
|
WrongArgCount {
|
|
function: String,
|
|
expected: usize,
|
|
found: usize,
|
|
},
|
|
/// Raised by `resolve`/`resolve_all`, not `set` — a formula may
|
|
/// legitimately reference a parameter that doesn't exist *yet* (order of
|
|
/// definition shouldn't matter, mirroring a spreadsheet); this only
|
|
/// surfaces once something actually tries to read the missing value.
|
|
UndefinedReference(String),
|
|
DivisionByZero,
|
|
}
|
|
|
|
impl fmt::Display for ParamError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
ParamError::Parse(msg) => write!(f, "formula error: {msg}"),
|
|
ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"),
|
|
ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")),
|
|
ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"),
|
|
ParamError::WrongArgCount {
|
|
function,
|
|
expected,
|
|
found,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"{function}() expects {expected} argument(s), found {found}"
|
|
)
|
|
}
|
|
ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"),
|
|
ParamError::DivisionByZero => write!(f, "division by zero"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ParamError {}
|
|
|
|
/// The builtin function set. It also supplies the reserved-word list that
|
|
/// `is_reserved_name` checks a parameter name against, so a formula
|
|
/// like `sqrt(x)` is never ambiguous between "call the builtin" and
|
|
/// "multiply by a parameter named sqrt".
|
|
const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"];
|
|
|
|
pub fn is_reserved_name(name: &str) -> bool {
|
|
BUILTIN_FUNCTIONS.contains(&name)
|
|
}
|
|
|
|
/// A valid parameter name: starts with a letter or underscore, followed by
|
|
/// letters, digits, or underscores.
|
|
pub fn is_valid_name(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
match chars.next() {
|
|
Some(c) if c.is_alphabetic() || c == '_' => {}
|
|
_ => return false,
|
|
}
|
|
chars.all(|c| c.is_alphanumeric() || c == '_')
|
|
}
|
|
|
|
/// A dimensional constraint's literal or named driving value.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum DrivingValue {
|
|
Literal(f64),
|
|
Named(String),
|
|
}
|
|
|
|
impl DrivingValue {
|
|
/// Resolves to a number: a literal resolves to itself (never fails); a
|
|
/// named reference resolves through `table`, which can fail the same
|
|
/// ways any [`ParameterTable::resolve`] call can (undefined reference,
|
|
/// division by zero — a cycle should be unreachable here since `set`
|
|
/// already refuses to create one).
|
|
pub fn resolve(&self, table: &ParameterTable) -> Result<f64, ParamError> {
|
|
match self {
|
|
DrivingValue::Literal(value) => Ok(*value),
|
|
DrivingValue::Named(name) => table.resolve(name),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<f64> for DrivingValue {
|
|
fn from(value: f64) -> Self {
|
|
DrivingValue::Literal(value)
|
|
}
|
|
}
|
|
|
|
fn call_builtin(name: &str, args: &[f64]) -> Result<f64, ParamError> {
|
|
fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> {
|
|
if args.len() != expected {
|
|
return Err(ParamError::WrongArgCount {
|
|
function: name.to_string(),
|
|
expected,
|
|
found: args.len(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
match name {
|
|
"sqrt" => {
|
|
check_arity(name, args, 1)?;
|
|
Ok(args[0].sqrt())
|
|
}
|
|
"abs" => {
|
|
check_arity(name, args, 1)?;
|
|
Ok(args[0].abs())
|
|
}
|
|
"pow" => {
|
|
check_arity(name, args, 2)?;
|
|
Ok(args[0].powf(args[1]))
|
|
}
|
|
"min" => {
|
|
check_arity(name, args, 2)?;
|
|
Ok(args[0].min(args[1]))
|
|
}
|
|
"max" => {
|
|
check_arity(name, args, 2)?;
|
|
Ok(args[0].max(args[1]))
|
|
}
|
|
_ => Err(ParamError::UnknownFunction(name.to_string())),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum Token {
|
|
Number(f64),
|
|
Ident(String),
|
|
Plus,
|
|
Minus,
|
|
Star,
|
|
Slash,
|
|
Caret,
|
|
LParen,
|
|
RParen,
|
|
Comma,
|
|
}
|
|
|
|
fn tokenize(src: &str) -> Result<Vec<Token>, ParamError> {
|
|
let chars: Vec<char> = src.chars().collect();
|
|
let mut tokens = Vec::new();
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
let c = chars[i];
|
|
if c.is_whitespace() {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
match c {
|
|
'+' => {
|
|
tokens.push(Token::Plus);
|
|
i += 1;
|
|
}
|
|
'-' => {
|
|
tokens.push(Token::Minus);
|
|
i += 1;
|
|
}
|
|
'*' => {
|
|
tokens.push(Token::Star);
|
|
i += 1;
|
|
}
|
|
'/' => {
|
|
tokens.push(Token::Slash);
|
|
i += 1;
|
|
}
|
|
'^' => {
|
|
tokens.push(Token::Caret);
|
|
i += 1;
|
|
}
|
|
'(' => {
|
|
tokens.push(Token::LParen);
|
|
i += 1;
|
|
}
|
|
')' => {
|
|
tokens.push(Token::RParen);
|
|
i += 1;
|
|
}
|
|
',' => {
|
|
tokens.push(Token::Comma);
|
|
i += 1;
|
|
}
|
|
_ if c.is_ascii_digit() || c == '.' => {
|
|
let start = i;
|
|
let mut seen_dot = c == '.';
|
|
i += 1;
|
|
while i < chars.len() {
|
|
let d = chars[i];
|
|
if d.is_ascii_digit() {
|
|
i += 1;
|
|
} else if d == '.' && !seen_dot {
|
|
seen_dot = true;
|
|
i += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// Scientific notation: 1e-3, 2.5E10. Only consumed when a
|
|
// digit (optionally signed) actually follows the e/E, so a
|
|
// bare trailing "e" is left for the identifier lexer instead
|
|
// (not reachable today since no builtin/constant is named
|
|
// "e", but keeps this branch from eating an identifier that
|
|
// happens to start with it, e.g. a formula like `2 * ecc`).
|
|
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
|
|
let mut j = i + 1;
|
|
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
|
|
j += 1;
|
|
}
|
|
if j < chars.len() && chars[j].is_ascii_digit() {
|
|
while j < chars.len() && chars[j].is_ascii_digit() {
|
|
j += 1;
|
|
}
|
|
i = j;
|
|
}
|
|
}
|
|
let text: String = chars[start..i].iter().collect();
|
|
let value: f64 = text
|
|
.parse()
|
|
.map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?;
|
|
tokens.push(Token::Number(value));
|
|
}
|
|
_ if c.is_alphabetic() || c == '_' => {
|
|
let start = i;
|
|
i += 1;
|
|
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
|
|
i += 1;
|
|
}
|
|
tokens.push(Token::Ident(chars[start..i].iter().collect()));
|
|
}
|
|
other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))),
|
|
}
|
|
}
|
|
Ok(tokens)
|
|
}
|
|
|
|
struct Parser<'a> {
|
|
tokens: &'a [Token],
|
|
pos: usize,
|
|
}
|
|
|
|
impl<'a> Parser<'a> {
|
|
fn peek(&self) -> Option<&Token> {
|
|
self.tokens.get(self.pos)
|
|
}
|
|
|
|
fn bump(&mut self) -> Option<Token> {
|
|
let t = self.tokens.get(self.pos).cloned();
|
|
if t.is_some() {
|
|
self.pos += 1;
|
|
}
|
|
t
|
|
}
|
|
|
|
fn expect(&mut self, expected: Token) -> Result<(), ParamError> {
|
|
match self.bump() {
|
|
Some(t) if t == expected => Ok(()),
|
|
Some(t) => Err(ParamError::Parse(format!(
|
|
"expected {expected:?}, found {t:?}"
|
|
))),
|
|
None => Err(ParamError::Parse(format!(
|
|
"expected {expected:?}, found end of formula"
|
|
))),
|
|
}
|
|
}
|
|
|
|
// Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary.
|
|
// `^` binds tighter than unary minus on its left operand but is
|
|
// right-associative on its own exponent (`parse_unary` recursing back
|
|
// into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` ==
|
|
// `2^(3^2)`).
|
|
fn parse_expr(&mut self) -> Result<Expr, ParamError> {
|
|
let mut lhs = self.parse_term()?;
|
|
loop {
|
|
match self.peek() {
|
|
Some(Token::Plus) => {
|
|
self.pos += 1;
|
|
lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?));
|
|
}
|
|
Some(Token::Minus) => {
|
|
self.pos += 1;
|
|
lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?));
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
Ok(lhs)
|
|
}
|
|
|
|
fn parse_term(&mut self) -> Result<Expr, ParamError> {
|
|
let mut lhs = self.parse_unary()?;
|
|
loop {
|
|
match self.peek() {
|
|
Some(Token::Star) => {
|
|
self.pos += 1;
|
|
lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?));
|
|
}
|
|
Some(Token::Slash) => {
|
|
self.pos += 1;
|
|
lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?));
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
Ok(lhs)
|
|
}
|
|
|
|
fn parse_unary(&mut self) -> Result<Expr, ParamError> {
|
|
match self.peek() {
|
|
Some(Token::Minus) => {
|
|
self.pos += 1;
|
|
Ok(Expr::Neg(Box::new(self.parse_unary()?)))
|
|
}
|
|
Some(Token::Plus) => {
|
|
self.pos += 1;
|
|
self.parse_unary()
|
|
}
|
|
_ => self.parse_power(),
|
|
}
|
|
}
|
|
|
|
fn parse_power(&mut self) -> Result<Expr, ParamError> {
|
|
let base = self.parse_primary()?;
|
|
if let Some(Token::Caret) = self.peek() {
|
|
self.pos += 1;
|
|
let exponent = self.parse_unary()?;
|
|
return Ok(Expr::Pow(Box::new(base), Box::new(exponent)));
|
|
}
|
|
Ok(base)
|
|
}
|
|
|
|
fn parse_primary(&mut self) -> Result<Expr, ParamError> {
|
|
match self.bump() {
|
|
Some(Token::Number(n)) => Ok(Expr::Number(n)),
|
|
Some(Token::Ident(name)) => {
|
|
if matches!(self.peek(), Some(Token::LParen)) {
|
|
self.pos += 1;
|
|
let mut args = Vec::new();
|
|
if !matches!(self.peek(), Some(Token::RParen)) {
|
|
loop {
|
|
args.push(self.parse_expr()?);
|
|
if matches!(self.peek(), Some(Token::Comma)) {
|
|
self.pos += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
self.expect(Token::RParen)?;
|
|
Ok(Expr::Call(name, args))
|
|
} else {
|
|
Ok(Expr::Ref(name))
|
|
}
|
|
}
|
|
Some(Token::LParen) => {
|
|
let inner = self.parse_expr()?;
|
|
self.expect(Token::RParen)?;
|
|
Ok(inner)
|
|
}
|
|
Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))),
|
|
None => Err(ParamError::Parse("unexpected end of formula".to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses a formula's text into an [`Expr`], without evaluating or
|
|
/// resolving any reference. `ParameterTable::set` is the usual entry point;
|
|
/// exposed standalone so a UI stage can validate-as-you-type without
|
|
/// touching the table.
|
|
pub fn parse(source: &str) -> Result<Expr, ParamError> {
|
|
let tokens = tokenize(source)?;
|
|
if tokens.is_empty() {
|
|
return Err(ParamError::Parse("empty formula".to_string()));
|
|
}
|
|
let mut parser = Parser {
|
|
tokens: &tokens,
|
|
pos: 0,
|
|
};
|
|
let expr = parser.parse_expr()?;
|
|
if parser.pos != tokens.len() {
|
|
return Err(ParamError::Parse(format!(
|
|
"unexpected trailing token {:?}",
|
|
tokens[parser.pos]
|
|
)));
|
|
}
|
|
Ok(expr)
|
|
}
|
|
|
|
/// One named parameter: a name plus its formula, both the original typed
|
|
/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`]
|
|
/// (`expr`, for evaluation).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Parameter {
|
|
pub name: String,
|
|
pub source: String,
|
|
pub expr: Expr,
|
|
}
|
|
|
|
/// Every named parameter for one document.
|
|
///
|
|
/// `parameters` is intentionally not `pub` (unlike the sibling
|
|
/// `ParametricConstraintSet::constraints`): this table's entire value is that
|
|
/// every stored formula is already known to be parse-valid and cycle-free,
|
|
/// an invariant only `set`/`remove` maintain. A direct external push could
|
|
/// silently violate it.
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct ParameterTable {
|
|
parameters: Vec<Parameter>,
|
|
/// The free-text description a parameter carries (the reference's
|
|
/// Description column), by name.
|
|
descriptions: HashMap<String, String>,
|
|
}
|
|
|
|
impl ParameterTable {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn description(&self, name: &str) -> &str {
|
|
self.descriptions.get(name).map_or("", String::as_str)
|
|
}
|
|
|
|
/// Stores `description` for an existing parameter; an empty text clears it.
|
|
pub fn set_description(&mut self, name: &str, description: &str) {
|
|
if !self.contains(name) {
|
|
return;
|
|
}
|
|
if description.trim().is_empty() {
|
|
self.descriptions.remove(name);
|
|
} else {
|
|
self.descriptions
|
|
.insert(name.to_string(), description.trim().to_string());
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.parameters.is_empty()
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.parameters.len()
|
|
}
|
|
|
|
/// Insertion order — the order parameters were first defined in, stable
|
|
/// across edits to existing ones. The natural listing order for a
|
|
/// future parameters panel.
|
|
pub fn iter(&self) -> impl Iterator<Item = &Parameter> {
|
|
self.parameters.iter()
|
|
}
|
|
|
|
pub fn get(&self, name: &str) -> Option<&Parameter> {
|
|
self.parameters.iter().find(|p| p.name == name)
|
|
}
|
|
|
|
pub fn contains(&self, name: &str) -> bool {
|
|
self.get(name).is_some()
|
|
}
|
|
|
|
fn index_of(&self, name: &str) -> Option<usize> {
|
|
self.parameters.iter().position(|p| p.name == name)
|
|
}
|
|
|
|
/// Defines a new parameter or redefines an existing one's formula (one
|
|
/// upsert operation — there is no separate "must not already exist"
|
|
/// entry point; a future UI stage that wants add-vs-rename semantics can
|
|
/// check `contains` itself first).
|
|
///
|
|
/// Rejects an invalid or reserved identifier, malformed formula, or
|
|
/// circular reference. A reference
|
|
/// to a parameter that doesn't exist *yet* is not rejected (order of
|
|
/// definition shouldn't matter, mirroring a spreadsheet); it only
|
|
/// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`]
|
|
/// actually needs that value.
|
|
pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> {
|
|
if !is_valid_name(name) || is_reserved_name(name) {
|
|
return Err(ParamError::InvalidName(name.to_string()));
|
|
}
|
|
let expr = parse(source)?;
|
|
self.check_no_cycle(name, &expr)?;
|
|
match self.index_of(name) {
|
|
Some(i) => {
|
|
self.parameters[i].source = source.to_string();
|
|
self.parameters[i].expr = expr;
|
|
}
|
|
None => self.parameters.push(Parameter {
|
|
name: name.to_string(),
|
|
source: source.to_string(),
|
|
expr,
|
|
}),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes a parameter by name. Returns whether one was actually
|
|
/// removed. Deliberately does not check whether another parameter's
|
|
/// formula still references `name` — that becomes an
|
|
/// [`ParamError::UndefinedReference`] for the referencing parameter the
|
|
/// next time it's resolved, the same graceful-degradation treatment an
|
|
/// unresolvable reference gets everywhere else in this module, rather
|
|
/// than a special "can't delete, still in use" rule this stage doesn't
|
|
/// need to invent (a future UI stage may still want to warn before
|
|
/// deleting — that's a UI-layer decision, not a data-layer one).
|
|
pub fn remove(&mut self, name: &str) -> bool {
|
|
self.descriptions.remove(name);
|
|
match self.index_of(name) {
|
|
Some(i) => {
|
|
self.parameters.remove(i);
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Would accepting `new_expr` as `name`'s formula close a circular
|
|
/// reference right now? Walks the dependency graph from each of
|
|
/// `new_expr`'s direct references, following each visited parameter's
|
|
/// *existing* stored formula, looking for a path back to `name`. A
|
|
/// reference to a name not currently in the table just terminates that
|
|
/// branch — no cycle through something that doesn't exist yet.
|
|
fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> {
|
|
let mut direct_refs = Vec::new();
|
|
new_expr.refs(&mut direct_refs);
|
|
for start in &direct_refs {
|
|
let mut visiting = Vec::new();
|
|
if let Some(mut path) = self.find_path(start, name, &mut visiting) {
|
|
let mut full = vec![name.to_string()];
|
|
full.append(&mut path);
|
|
return Err(ParamError::Cycle(full));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Depth-first search from `current` to `target`, following each
|
|
/// visited parameter's existing formula's references. Returns the path
|
|
/// (starting with `current`) if `target` is reachable.
|
|
fn find_path(
|
|
&self,
|
|
current: &str,
|
|
target: &str,
|
|
visiting: &mut Vec<String>,
|
|
) -> Option<Vec<String>> {
|
|
if current == target {
|
|
return Some(vec![current.to_string()]);
|
|
}
|
|
if visiting.iter().any(|v| v == current) {
|
|
// A cycle that doesn't involve `target` shouldn't be reachable
|
|
// (`set` refuses to create one), but bail out rather than loop
|
|
// forever if that invariant is ever violated some other way
|
|
// (e.g. a foreign/hand-edited file deserialized straight into a
|
|
// table without going through `set`).
|
|
return None;
|
|
}
|
|
let param = self.get(current)?;
|
|
visiting.push(current.to_string());
|
|
let mut refs = Vec::new();
|
|
param.expr.refs(&mut refs);
|
|
for r in &refs {
|
|
if let Some(mut rest) = self.find_path(r, target, visiting) {
|
|
let mut path = vec![current.to_string()];
|
|
path.append(&mut rest);
|
|
visiting.pop();
|
|
return Some(path);
|
|
}
|
|
}
|
|
visiting.pop();
|
|
None
|
|
}
|
|
|
|
/// Resolves one parameter's numeric value and dependency chain using a
|
|
/// fresh memo cache.
|
|
pub fn resolve(&self, name: &str) -> Result<f64, ParamError> {
|
|
let mut cache = HashMap::new();
|
|
let mut stack = Vec::new();
|
|
self.resolve_inner(name, &mut cache, &mut stack)
|
|
}
|
|
|
|
/// Resolves every parameter, isolating a failure to just the parameters
|
|
/// it actually affects rather than aborting the whole table — the same
|
|
/// don't-let-one-bad-item-take-down-everything-else treatment this
|
|
/// codebase already gives dangling constraints and similar derived-data
|
|
/// failures elsewhere.
|
|
pub fn resolve_all(&self) -> HashMap<String, Result<f64, ParamError>> {
|
|
let mut out = HashMap::with_capacity(self.parameters.len());
|
|
for param in &self.parameters {
|
|
out.insert(param.name.clone(), self.resolve(¶m.name));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn resolve_inner(
|
|
&self,
|
|
name: &str,
|
|
cache: &mut HashMap<String, f64>,
|
|
stack: &mut Vec<String>,
|
|
) -> Result<f64, ParamError> {
|
|
if let Some(v) = cache.get(name) {
|
|
return Ok(*v);
|
|
}
|
|
if stack.iter().any(|s| s == name) {
|
|
// Defense in depth only — `set` already refuses to create a
|
|
// cycle; see `find_path`'s doc comment for how one could still
|
|
// end up here.
|
|
let mut cyc = stack.clone();
|
|
cyc.push(name.to_string());
|
|
return Err(ParamError::Cycle(cyc));
|
|
}
|
|
let param = self
|
|
.get(name)
|
|
.ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?;
|
|
stack.push(name.to_string());
|
|
let value = self.eval(¶m.expr, cache, stack)?;
|
|
stack.pop();
|
|
cache.insert(name.to_string(), value);
|
|
Ok(value)
|
|
}
|
|
|
|
fn eval(
|
|
&self,
|
|
expr: &Expr,
|
|
cache: &mut HashMap<String, f64>,
|
|
stack: &mut Vec<String>,
|
|
) -> Result<f64, ParamError> {
|
|
Ok(match expr {
|
|
Expr::Number(n) => *n,
|
|
Expr::Ref(name) => self.resolve_inner(name, cache, stack)?,
|
|
Expr::Neg(a) => -self.eval(a, cache, stack)?,
|
|
Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?,
|
|
Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?,
|
|
Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?,
|
|
Expr::Div(a, b) => {
|
|
let denom = self.eval(b, cache, stack)?;
|
|
if denom == 0.0 {
|
|
return Err(ParamError::DivisionByZero);
|
|
}
|
|
self.eval(a, cache, stack)? / denom
|
|
}
|
|
Expr::Pow(a, b) => self
|
|
.eval(a, cache, stack)?
|
|
.powf(self.eval(b, cache, stack)?),
|
|
Expr::Call(fname, args) => {
|
|
let mut values = Vec::with_capacity(args.len());
|
|
for arg in args {
|
|
values.push(self.eval(arg, cache, stack)?);
|
|
}
|
|
call_builtin(fname, &values)?
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Scene {
|
|
/// The document's named-parameter table. Use
|
|
/// [`Scene::named_parameters_mut`] to
|
|
/// define/redefine/remove a parameter.
|
|
pub fn named_parameters(&self) -> &ParameterTable {
|
|
&self.named_parameters
|
|
}
|
|
|
|
pub fn named_parameters_mut(&mut self) -> &mut ParameterTable {
|
|
&mut self.named_parameters
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_arithmetic_with_ordinary_precedence() {
|
|
// 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +.
|
|
assert_eq!(eval_literal("2 + 3 * 4"), 14.0);
|
|
// (2 + 3) * 4 == 20 -- parens override precedence.
|
|
assert_eq!(eval_literal("(2 + 3) * 4"), 20.0);
|
|
// 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power.
|
|
assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0);
|
|
// Unary minus binds tighter than power's left operand is *read*,
|
|
// but -2^2 must still equal -(2^2) = -4 under this grammar (unary
|
|
// parses before power), matching common calculator convention.
|
|
assert_eq!(eval_literal("-2^2"), -4.0);
|
|
assert_eq!(eval_literal("2^-2"), 0.25);
|
|
assert_eq!(eval_literal("10 / 4"), 2.5);
|
|
assert_eq!(eval_literal("-(3 + 4)"), -7.0);
|
|
}
|
|
|
|
fn eval_literal(src: &str) -> f64 {
|
|
let table = ParameterTable::new();
|
|
let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}"));
|
|
table
|
|
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
|
.unwrap_or_else(|e| panic!("eval '{src}': {e}"))
|
|
}
|
|
|
|
#[test]
|
|
fn parses_scientific_notation() {
|
|
assert_eq!(eval_literal("1e3"), 1000.0);
|
|
assert_eq!(eval_literal("1.5e-2"), 0.015);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_malformed_formulas() {
|
|
assert!(matches!(parse(""), Err(ParamError::Parse(_))));
|
|
assert!(matches!(parse("1 +"), Err(ParamError::Parse(_))));
|
|
assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_))));
|
|
assert!(matches!(parse("1 2"), Err(ParamError::Parse(_))));
|
|
assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_functions_evaluate_and_check_arity() {
|
|
assert_eq!(eval_literal("sqrt(16)"), 4.0);
|
|
assert_eq!(eval_literal("abs(-5)"), 5.0);
|
|
assert_eq!(eval_literal("min(3, 7)"), 3.0);
|
|
assert_eq!(eval_literal("max(3, 7)"), 7.0);
|
|
assert_eq!(eval_literal("pow(2, 10)"), 1024.0);
|
|
|
|
let table = ParameterTable::new();
|
|
let expr = parse("sqrt(1, 2)").unwrap();
|
|
let err = table
|
|
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err,
|
|
ParamError::WrongArgCount {
|
|
function: "sqrt".to_string(),
|
|
expected: 1,
|
|
found: 2
|
|
}
|
|
);
|
|
|
|
let expr = parse("bogus(1)").unwrap();
|
|
let err = table
|
|
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
|
.unwrap_err();
|
|
assert_eq!(err, ParamError::UnknownFunction("bogus".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn division_by_zero_is_reported_not_panicked() {
|
|
let table = ParameterTable::new();
|
|
let expr = parse("1 / 0").unwrap();
|
|
assert_eq!(
|
|
table.eval(&expr, &mut HashMap::new(), &mut Vec::new()),
|
|
Err(ParamError::DivisionByZero)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_rejects_invalid_and_reserved_names() {
|
|
let mut table = ParameterTable::new();
|
|
assert!(matches!(
|
|
table.set("2bad", "1"),
|
|
Err(ParamError::InvalidName(_))
|
|
));
|
|
assert!(matches!(
|
|
table.set("has space", "1"),
|
|
Err(ParamError::InvalidName(_))
|
|
));
|
|
assert!(matches!(
|
|
table.set("", "1"),
|
|
Err(ParamError::InvalidName(_))
|
|
));
|
|
assert!(matches!(
|
|
table.set("sqrt", "1"),
|
|
Err(ParamError::InvalidName(_))
|
|
));
|
|
assert!(table.set("_valid1", "1").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_literal_parameter_resolves_to_its_number() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("plate_len", "42.5").unwrap();
|
|
assert_eq!(table.resolve("plate_len"), Ok(42.5));
|
|
}
|
|
|
|
#[test]
|
|
fn a_formula_resolves_through_a_reference_chain() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("hole_dia", "5").unwrap();
|
|
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
|
|
table.set("row_span", "3 * hole_spacing").unwrap();
|
|
assert_eq!(table.resolve("hole_spacing"), Ok(11.5));
|
|
assert_eq!(table.resolve("row_span"), Ok(34.5));
|
|
}
|
|
|
|
#[test]
|
|
fn resolving_an_undefined_reference_reports_which_name_is_missing() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("a", "b + 1").unwrap();
|
|
assert_eq!(
|
|
table.resolve("a"),
|
|
Err(ParamError::UndefinedReference("b".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_rejects_a_direct_self_reference() {
|
|
let mut table = ParameterTable::new();
|
|
let err = table.set("a", "a + 1").unwrap_err();
|
|
assert!(matches!(err, ParamError::Cycle(_)));
|
|
assert!(
|
|
!table.contains("a"),
|
|
"a rejected formula must not be stored"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("a", "b").unwrap();
|
|
// b doesn't exist yet -- not a cycle, a is just unresolved for now.
|
|
assert_eq!(
|
|
table.resolve("a"),
|
|
Err(ParamError::UndefinedReference("b".to_string()))
|
|
);
|
|
// Defining b = a closes the loop right here and must be rejected,
|
|
// leaving b undefined rather than silently accepted.
|
|
let err = table.set("b", "a").unwrap_err();
|
|
assert!(matches!(err, ParamError::Cycle(_)));
|
|
assert!(!table.contains("b"));
|
|
// a's formula (still "b") is untouched -- it stays unresolved, not
|
|
// corrupted by the rejected attempt.
|
|
assert_eq!(
|
|
table.resolve("a"),
|
|
Err(ParamError::UndefinedReference("b".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("a", "1").unwrap();
|
|
table.set("b", "a + 1").unwrap();
|
|
// Redefining a in terms of b must be rejected (a <- b <- a)...
|
|
assert!(table.set("a", "b + 1").is_err());
|
|
// ...but redefining a to something unrelated is always fine.
|
|
table.set("a", "5").unwrap();
|
|
assert_eq!(table.resolve("a"), Ok(5.0));
|
|
assert_eq!(table.resolve("b"), Ok(6.0));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("good", "10").unwrap();
|
|
table.set("bad", "missing + 1").unwrap();
|
|
table.set("depends_on_bad", "bad * 2").unwrap();
|
|
|
|
let results = table.resolve_all();
|
|
assert_eq!(results.get("good"), Some(&Ok(10.0)));
|
|
assert_eq!(
|
|
results.get("bad"),
|
|
Some(&Err(ParamError::UndefinedReference("missing".to_string())))
|
|
);
|
|
assert_eq!(
|
|
results.get("depends_on_bad"),
|
|
Some(&Err(ParamError::UndefinedReference("missing".to_string())))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("a", "1").unwrap();
|
|
table.set("b", "a + 1").unwrap();
|
|
assert!(table.remove("a"));
|
|
assert!(
|
|
!table.remove("a"),
|
|
"removing an already-removed name reports false"
|
|
);
|
|
assert_eq!(
|
|
table.resolve("b"),
|
|
Err(ParamError::UndefinedReference("a".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_upserts_an_existing_parameters_formula() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("a", "1").unwrap();
|
|
table.set("a", "2").unwrap();
|
|
assert_eq!(table.len(), 1, "redefining must not create a second entry");
|
|
assert_eq!(table.resolve("a"), Ok(2.0));
|
|
}
|
|
|
|
#[test]
|
|
fn iter_preserves_insertion_order_across_redefinitions() {
|
|
let mut table = ParameterTable::new();
|
|
table.set("z", "1").unwrap();
|
|
table.set("a", "2").unwrap();
|
|
table.set("z", "3").unwrap(); // redefine, should not move to the end
|
|
let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect();
|
|
assert_eq!(names, vec!["z", "a"]);
|
|
}
|
|
}
|