Compare commits

..
4 changed files with 104 additions and 9 deletions
+17 -3
View File
@@ -8,6 +8,9 @@ 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)]
@@ -309,11 +312,22 @@ 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) {
if let Ok(id) = crate::add(d, c, &crate::SolveOptions::default()) {
added.push(id);
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(_) => {}
}
}
added
(added, false)
}
+43 -4
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, infer, infer_placed};
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, autoconstrain_report, infer, infer_placed};
pub use dof::{DofReport, DofStatus, ObjectDof, dof};
pub use expr::{Env, ExprError};
pub use model::{ref_points, rigid_points, supported};
@@ -53,6 +53,14 @@ 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;
@@ -335,8 +343,14 @@ 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 self.built.len() <= MAX_CONFLICT_PROBES {
if probe && self.built.len() <= MAX_CONFLICT_PROBES {
for i in 0..self.built.len() {
if let Ok(o) = self.run(Some(i))
&& o.converged
@@ -741,7 +755,14 @@ 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, mut c: Constraint, opts: &SolveOptions) -> Result<u32, SolveError> {
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> {
if d.constraints.len() >= MAX_CONSTRAINTS {
return Err(SolveError::Other("too many constraints".into()));
}
@@ -777,10 +798,24 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
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.
let mut ids = comp.conflicts(&o.x);
// 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);
ids.retain(|i| *i != id);
ids.insert(0, id);
return Err(SolveError::Conflict { ids });
@@ -819,6 +854,10 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
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,3 +516,41 @@ 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}");
}
+6 -2
View File
@@ -821,11 +821,15 @@ 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 = cs::autoconstrain(&mut d, &hs, &o);
let (ids, stopped) = cs::autoconstrain_report(&mut d, &hs, &o);
if !ids.is_empty() {
*s.doc_mut()? = d;
}
Ok(json!({ "added": ids.len(), "ids": ids, "message": format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len()) }))
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 }))
}
fn run_delconstraint(s: &mut Session, p: &Value) -> Result<Value> {