Compare commits

..
Author SHA1 Message Date
FredApps 28241abb8f Merge main into release for CADCraft v0.20.0 2026-11-11 16:38:54 +01:00
FredAppsand.CooCoo 51f5bbcc21 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954) (#3024)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.

(cherry picked from commit 6f0f1fefa8)

Co-authored-by: .CooCoo <RobertLeeLW@gmail.com>
2026-11-10 19:49:24 +01:00
Dylan M. Taylor 227e3c385b Release 0.19.0 2026-11-09 06:29:44 -05:00
4 changed files with 9 additions and 104 deletions
+3 -17
View File
@@ -8,9 +8,6 @@ use cadcraft_geom::{Line, Vec2};
pub const MAX_OBJECTS: usize = 200;
/// Most candidates returned.
pub const MAX_CANDIDATES: usize = 2000;
/// Residual-evaluation work all the solves of one AUTOCONSTRAIN may make together (four
/// full-size solves; a few thousand small ones).
const MAX_AUTO_WORK: f64 = 4.0 * crate::MAX_EVAL_WORK;
/// Inference options.
#[derive(Clone, Debug)]
@@ -312,22 +309,11 @@ fn on_arc(d: &Drawing, h: Handle, c: Vec2, p: Vec2) -> bool {
/// Infer and apply constraints over `handles`; returns the ids added. Redundant or conflicting
/// candidates are skipped.
pub fn autoconstrain(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> Vec<u32> {
autoconstrain_report(d, handles, o).0
}
/// [`autoconstrain`], and whether it stopped early because the solver-work allowance of the command
/// was spent (the remaining candidates were not tried).
pub fn autoconstrain_report(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> (Vec<u32>, bool) {
let mut added = Vec::new();
// One allowance of solver work for all the candidates: each is a full solve of its component.
let mut work = MAX_AUTO_WORK;
for c in infer(d, handles, o) {
match crate::add_within(d, c, &crate::SolveOptions::default(), &mut work) {
Ok(id) => added.push(id),
// The allowance is spent: what was added stays, the remaining candidates are not tried.
Err(crate::SolveError::TooLarge) if work < 0.0 => return (added, true),
Err(_) => {}
if let Ok(id) = crate::add(d, c, &crate::SolveOptions::default()) {
added.push(id);
}
}
(added, false)
added
}
+4 -43
View File
@@ -36,7 +36,7 @@ use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
use cadcraft_geom::Vec2;
use serde::Serialize;
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, autoconstrain_report, infer, infer_placed};
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, infer, infer_placed};
pub use dof::{DofReport, DofStatus, ObjectDof, dof};
pub use expr::{Env, ExprError};
pub use model::{ref_points, rigid_points, supported};
@@ -53,14 +53,6 @@ const MAX_ITER: usize = 200;
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
/// shared by solving, redundancy checks and DOF analysis.
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
/// Most multiply-adds (rows x columns squared) of the dense rank factorisations one Fix makes:
/// about a thousand parameters locked at once is 1e9; 2e10 is a few seconds.
const MAX_QR_WORK: f64 = 2e10;
/// Whether a dense rank factorisation of a `rows` x `cols` matrix passes [`MAX_QR_WORK`].
fn qr_too_large(rows: usize, cols: usize) -> bool {
(rows as f64) * (cols as f64) * (cols as f64) > MAX_QR_WORK
}
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
const TRIAL_EVALS: f64 = 14.0;
@@ -343,14 +335,8 @@ impl Comp {
}
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
self.conflicts_probing(x_failed, true)
}
/// [`Comp::conflicts`]; `probe` false skips the re-solves that look for the constraints whose
/// removal lets the rest converge.
fn conflicts_probing(&self, x_failed: &[f64], probe: bool) -> Vec<u32> {
let mut ids = Vec::new();
if probe && self.built.len() <= MAX_CONFLICT_PROBES {
if self.built.len() <= MAX_CONFLICT_PROBES {
for i in 0..self.built.len() {
if let Ok(o) = self.run(Some(i))
&& o.converged
@@ -755,14 +741,7 @@ pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
/// Add a constraint: assigns its id (and name/expression for dimensional constraints when
/// empty), refuses redundant or conflicting constraints, solves and writes the geometry back.
/// On error the drawing is unchanged.
pub fn add(d: &mut Drawing, c: Constraint, opts: &SolveOptions) -> Result<u32, SolveError> {
let mut unlimited = f64::INFINITY;
add_within(d, c, opts, &mut unlimited)
}
/// [`add`] charging the solve's residual-evaluation work to `work`: `TooLarge` (nothing changed)
/// once it would take more than is left.
pub(crate) fn add_within(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions, work: &mut f64) -> Result<u32, SolveError> {
pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u32, SolveError> {
if d.constraints.len() >= MAX_CONSTRAINTS {
return Err(SolveError::Other("too many constraints".into()));
}
@@ -798,24 +777,10 @@ pub(crate) fn add_within(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions
let Some(comp) = comps.into_iter().find(|k| k.built.iter().any(|b| b.id == id)) else {
return Err(SolveError::Invalid { id, msg: "could not be interpreted".into() });
};
*work -= comp.eval_work(&comp.free(None), None);
if *work < 0.0 {
return Err(SolveError::TooLarge);
}
let o = comp.run(None)?;
if !o.converged {
// The new constraint first, then the existing ones it fights with.
// Each probe is another solve (up to one per constraint): they are charged too, and skipped
// when the allowance cannot pay for them.
let probes = if comp.built.len() <= MAX_CONFLICT_PROBES { comp.built.len() as f64 } else { 0.0 };
let cost = probes * comp.eval_work(&comp.free(None), None);
// Paid for before it is spent: when the allowance cannot cover the probes they are skipped and
// it is left as it was for the candidates still to try.
let affordable = cost <= *work;
if affordable {
*work -= cost;
}
let mut ids = comp.conflicts_probing(&o.x, affordable);
let mut ids = comp.conflicts(&o.x);
ids.retain(|i| *i != id);
ids.insert(0, id);
return Err(SolveError::Conflict { ids });
@@ -854,10 +819,6 @@ pub(crate) fn add_within(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
let rows_old = comp.rows(Some(pos));
let nf = before.len();
// The synthetic lock rows enlarge the matrix after the solver's own limits were checked.
if qr_too_large(rows_old + locked.len(), nf) {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, Some(pos));
let mut jac = lm::jacobian(&f, &o.x, &before, rows_old);
let r_old = lm::rank_qr(&jac, rows_old, nf);
-38
View File
@@ -516,41 +516,3 @@ fn next_name_skips_names_in_use() {
});
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
}
#[test]
fn a_fix_over_a_huge_component_is_refused_before_its_dense_factorisation() {
assert!(!super::qr_too_large(1_000, 1_000));
assert!(super::qr_too_large(3_000, 3_000));
assert!(super::qr_too_large(usize::MAX, 2));
}
#[test]
fn a_spent_work_allowance_refuses_a_constraint_and_leaves_the_drawing() {
let mut d = Drawing::default();
let l = line(&mut d, (0.0, 0.0), (10.0, 0.01));
let before = d.clone();
let c = con(ConstraintKind::Horizontal, &[w(l)]);
assert!(matches!(add_within(&mut d, c.clone(), &SolveOptions::default(), &mut 0.0), Err(SolveError::TooLarge)));
assert_eq!(d, before);
let mut plenty = 1e9;
assert!(add_within(&mut d, c, &SolveOptions::default(), &mut plenty).is_ok());
assert!(plenty < 1e9, "the solve was charged");
let (_, stopped) = autoconstrain_report(&mut d, &[l], &InferOptions::default());
assert!(!stopped, "a small command never spends its allowance");
}
#[test]
fn skipped_conflict_probes_do_not_spend_the_allowance() {
let mut d = Drawing::default();
let b = line(&mut d, (0.0, 0.0), (3.0, 4.0));
ok(&mut d, con(ConstraintKind::Fix, &[w(b)]));
let candidate = || con(ConstraintKind::Vertical, &[w(b)]);
// The whole cost of the conflicting candidate: its solve and its probes.
let mut rich = 1e12;
assert!(matches!(add_within(&mut d.clone(), candidate(), &SolveOptions::default(), &mut rich), Err(SolveError::Conflict { .. })));
let total = 1e12 - rich;
// Enough for the solve but not the probes: they are skipped, and what is left stays positive.
let mut work = total * 0.6;
assert!(matches!(add_within(&mut d, candidate(), &SolveOptions::default(), &mut work), Err(SolveError::Conflict { .. })));
assert!(work > 0.0 && work < total * 0.6, "{work} of {total}");
}
+2 -6
View File
@@ -821,15 +821,11 @@ fn run_autoconstrain(s: &mut Session, p: &Value) -> Result<Value> {
o.types = t.iter().filter_map(Value::as_str).map(|n| kind_from_name(n).map_or_else(|| n.to_string(), |k| k.name().to_string())).collect();
}
let mut d = s.doc()?.clone();
let (ids, stopped) = cs::autoconstrain_report(&mut d, &hs, &o);
let ids = cs::autoconstrain(&mut d, &hs, &o);
if !ids.is_empty() {
*s.doc_mut()? = d;
}
let mut message = format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len());
if stopped {
message.push_str("; stopped early: the rest need more solver work than one command may spend, so run AUTOCONSTRAIN on fewer objects");
}
Ok(json!({ "added": ids.len(), "ids": ids, "stopped": stopped, "message": message }))
Ok(json!({ "added": ids.len(), "ids": ids, "message": format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len()) }))
}
fn run_delconstraint(s: &mut Session, p: &Value) -> Result<Value> {