diff --git a/crates/constraints/src/expr.rs b/crates/constraints/src/expr.rs index 67f87c24..4bc25a55 100644 --- a/crates/constraints/src/expr.rs +++ b/crates/constraints/src/expr.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; /// Longest expression accepted (characters). pub const MAX_LEN: usize = 1024; -/// Deepest nesting (parentheses, unary minus) and parameter reference chain. +/// Deepest syntax tree and combined expression/parameter evaluation path. pub const MAX_DEPTH: usize = 64; #[derive(Clone, Debug, PartialEq, thiserror::Error)] @@ -165,6 +165,20 @@ pub fn parse(text: &str) -> Result { if p.peek().is_some() { return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim()))); } + let mut pending = vec![(&e, 1)]; + while let Some((node, depth)) = pending.pop() { + if depth > MAX_DEPTH { + return Err(ExprError::TooDeep); + } + match node { + Expr::Neg(a) => pending.push((a, depth + 1)), + Expr::Bin(_, a, b) => { + pending.push((a, depth + 1)); + pending.push((b, depth + 1)); + } + _ => {} + } + } Ok(e) } @@ -224,12 +238,19 @@ pub fn rename(text: &str, old: &str, new: &str) -> String { /// Evaluate with a variable lookup. pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result) -> Result { + eval_at_depth(e, &mut |n, _| lookup(n), 0) +} + +fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result, depth: usize) -> Result { + if depth >= MAX_DEPTH { + return Err(ExprError::TooDeep); + } let v = match e { Expr::Num(v) => *v, - Expr::Var(n) => lookup(n)?, - Expr::Neg(a) => -eval_with(a, lookup)?, + Expr::Var(n) => lookup(n, depth + 1)?, + Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?, Expr::Bin(op, a, b) => { - let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?); + let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?); match op { '+' => x + y, '-' => x - y, @@ -273,9 +294,12 @@ impl Env { /// Value of a named parameter. pub fn value(&mut self, name: &str) -> Result { let mut stack = Vec::new(); - self.value_inner(name, &mut stack) + self.value_inner(name, &mut stack, 0) } - fn value_inner(&mut self, name: &str, stack: &mut Vec) -> Result { + fn value_inner(&mut self, name: &str, stack: &mut Vec, depth: usize) -> Result { + if depth >= MAX_DEPTH { + return Err(ExprError::TooDeep); + } if let Some(v) = self.cache.get(name) { return Ok(*v); } @@ -288,7 +312,7 @@ impl Env { let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?; let e = parse(&text)?; stack.push(name.to_string()); - let r = eval_with(&e, &mut |n| self.value_inner(n, stack)); + let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth); stack.pop(); let v = r?; self.cache.insert(name.to_string(), v); @@ -298,7 +322,7 @@ impl Env { pub fn eval(&mut self, text: &str) -> Result { let e = parse(text)?; let mut stack = Vec::new(); - eval_with(&e, &mut |n| self.value_inner(n, &mut stack)) + eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0) } } @@ -351,6 +375,31 @@ mod tests { assert!(chain.value("p0").is_err()); } + #[test] + fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() { + std::thread::Builder::new() + .stack_size(1024 * 1024) + .spawn(|| { + for op in ["+", "-", "*", "/"] { + let within = vec!["1"; MAX_DEPTH].join(op); + assert!(eval_const(&within).is_ok()); + assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep)); + } + let mut env = Env::new(); + for i in 0..MAX_DEPTH { + env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+"))); + } + env.insert(&format!("p{MAX_DEPTH}"), "1"); + assert_eq!(env.value("p0"), Err(ExprError::TooDeep)); + assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep)); + env.insert("safe", "2*3"); + assert_eq!(env.eval("safe+1"), Ok(7.0)); + }) + .unwrap() + .join() + .unwrap(); + } + #[test] fn rename_replaces_whole_names_only() { assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)"); diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 58fe7954..74dfc158 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (JOIN Close on a single arc or elliptical arc) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -119,7 +119,7 @@ lines are scanline families clipped against the tessellated loops (`render/src/h | Item | AutoCAD | CADCraft (`crates/constraints`) | |---|---|---| | Geometric constraints (12) | coincident, collinear, concentric, fix, parallel, perpendicular, horizontal, vertical, tangent, smooth (G2), symmetric, equal | all 12; Smooth is G2 (common point, tangent and curvature, `curves.rs`) between a spline and a line, arc, elliptical arc, polyline segment or spline; between two non-spline curves it stays tangency | -| Dimensional constraints | linear, horizontal, vertical, aligned, angular, radial, diameter; dynamic and annotational; reference | distance (aligned/horizontal/vertical), angular, radius, diameter; parameters and expressions (`expr.rs`) | +| Dimensional constraints | linear, horizontal, vertical, aligned, angular, radial, diameter; dynamic and annotational; reference | distance (aligned/horizontal/vertical), angular, radius, diameter; parameters and expressions (`expr.rs`), with one depth limit across syntax trees and parameter references | | Geometry | lines, arcs, circles, polylines, ellipses, splines, blocks | line, circle, arc, polyline, point; ellipse and elliptical arc (centre, axis ends and arc ends as points; tangent, coincident, concentric, horizontal/vertical on the major axis); spline (fit or control points as points; tangent and smooth at its ends; open clamped or fit splines); block reference (insertion point, and the block's own points riding rigidly with position and rotation) (`model.rs`) | | Algorithm | proprietary | damped Gauss–Newton / Levenberg–Marquardt, numeric Jacobian, per connected component; MAX_ITER 200, MAX_ROWS 800; curve–curve tangency with an ellipse through auxiliary contact parameters | | Over-constraint | refused with a message | redundancy by Jacobian rank (Householder QR with column pivoting, rows and columns balanced), including Fix; the message names the constraints the new one duplicates; conflicts by removal probing (up to 48 probes) | @@ -157,6 +157,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) | | 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (`crates/geom/src/property_tests.rs`): intersections, closest points, three-point arcs, bounds, vector identities; hostile f64s never panic; known answers at 1e6–1e7 |