Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor d43ce63cc0 constraints: share geometry preparation across an operation and budget spline rebuilds
Endpoint residuals rebuilt a fit spline through a dense interpolation
solve on every evaluation, so many Tangent/Smooth constraints on one
spline, or numerical differentiation along other objects' parameters,
repeated identical O(n^3) rebuilds; solving had no budget for them.
purge_invalid built a fresh model per constraint, re-sampling the same
block for every reference, and an Insert naming no block scanned every
block definition case-insensitively.

- Models built for one operation share a Prep: block samples are taken
  once per block (in block coordinates, transformed per Insert), block
  names resolve through a lazily built case-insensitive index, and the
  last rebuild of each spline is reused at the same defining points.
- Comp::run and the Fix redundancy check refuse a system whose
  per-iteration residual work, including spline rebuilds, exceeds the
  limit DOF analysis already applied (MAX_EVAL_WORK).
- Spline templates keep only what a rebuild reads: no fit points for a
  control spline, no control net for a fit spline.
- purge_invalid validates every constraint with one Prep.
2026-11-02 20:54:15 -05:00
Tanish Manku fdfe4d95a8 Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670)
* Autosave: a failed write is shown until one works (E6)

A failure only reached the log, so someone could work for hours with no
recovery copy. The autosaver keeps why each drawing's write failed; the app
says it once at the command line and shows a notice that doesn't block saving.
It ends when a write works or the drawing no longer needs a copy.

* Crash recovery: offered at the first relaunch, not 5 minutes later (E5)

A crashed app's lock file looked alive until it went stale, so a relaunch
within 5 minutes skipped its autosaves, and a suspended app's looked dead.
A running app now holds a system lock on the file, which the OS drops however
the process ends. File age still decides where locks don't work and for lock
files of older versions, which don't record holding one.
2026-11-02 18:06:35 -07:00
8 changed files with 616 additions and 98 deletions
+36 -8
View File
@@ -11,16 +11,13 @@ use std::collections::HashSet;
use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
use crate::model::{Model, Prep};
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000;
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build_budgeted(d, &[h], &mut members_left);
let m = Model::build_with(d, &[h], &mut prep);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -237,5 +234,36 @@ mod budget_tests {
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
// Solving it is refused for the same reason, before any Jacobian is built.
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
}
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
/// and the solve is refused by its rebuild budget instead of running.
#[test]
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
use cadcraft_doc::{Constraint, Line};
use cadcraft_geom::Vec3;
let mut d = Drawing::default();
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
let l =
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
for id in 1..=200 {
d.constraints.push(Constraint {
id,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
name: String::new(),
expr: String::new(),
});
}
let cons: Vec<&Constraint> = d.constraints.iter().collect();
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
assert!(skipped.is_empty());
let comp = comps.first().unwrap();
assert_eq!(comp.built.len(), 200);
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
assert!(crate::purge_invalid(&mut d).is_empty());
}
}
+56 -9
View File
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
pub use expr::{Env, ExprError};
pub use model::{ref_points, rigid_points, supported};
use model::Model;
use model::{Model, Prep};
use residual::{Built, Form};
/// Most constraints one drawing may hold.
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
const MAX_CONFLICT_PROBES: usize = 48;
/// Solver iterations.
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;
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
const TRIAL_EVALS: f64 = 14.0;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum SolveError {
@@ -251,12 +256,41 @@ impl Comp {
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
}
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
/// are shared by residuals at the same points, see [`Model`]).
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
let rows = self.rows(skip) as f64;
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
let mut splines: Vec<Handle> = Vec::new();
for (i, b) in self.built.iter().enumerate() {
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
}
}
splines.sort_unstable();
splines.dedup();
let rebuilds: f64 = splines
.iter()
.filter_map(|h| self.model.slot(*h))
.map(|s| {
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
})
.sum();
evals * rows + rebuilds
}
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
let free = self.free(skip);
let rows = self.rows(skip);
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
return Err(SolveError::TooLarge);
}
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
@@ -410,24 +444,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
/// its member budget, spline rebuilds) between every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
pub(crate) fn build_with(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
prep: &mut Prep,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d);
let mut skipped = Vec::new();
let mut comps = Vec::new();
for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build_budgeted(d, &handles, members_left);
let mut model = Model::build_with(d, &handles, prep);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
@@ -640,6 +674,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
/// Check that a constraint's references make sense for its type.
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
for h in &handles {
match d.entity(*h) {
@@ -648,7 +687,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
_ => {}
}
}
let mut model = Model::build(d, &handles);
let mut model = Model::build_with(d, &handles, prep);
interpret(c, &mut model, 1.0).map(|_| ())
}
@@ -665,7 +704,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
/// Returns the removed ids.
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
let mut gone = Vec::new();
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
// One preparation for every check: each block is sampled and each spline rebuilt once,
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
// a constraint is never dropped because earlier ones spent it: every block is still
// examined at most once, up to its own member limit.
let mut prep = Prep::new(usize::MAX);
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
let mut i = 0;
d.constraints.retain(|c| {
let k = keep.get(i).copied().unwrap_or(true);
@@ -752,6 +796,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
{
let before = comp.free(Some(pos));
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let after = comp.free(None);
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
let rows_old = comp.rows(Some(pos));
+193 -62
View File
@@ -14,9 +14,11 @@
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
//! a tangency between curves); they are never written back.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
use crate::curves::{Ell, Frame, clamped, spline_end};
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
pub len: usize,
}
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
#[derive(Clone, Debug, Default)]
pub(crate) struct Model {
pub slots: Vec<Slot>,
@@ -79,20 +84,75 @@ pub(crate) struct Model {
splines: HashMap<Handle, GSpline>,
/// Block reference rigid points relative to the insertion point at rotation 0.
rigid: HashMap<Handle, Vec<Vec2>>,
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
/// same spline at the same defining points (several constraints on one spline, or derivatives
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
rebuilt: Rc<RefCell<SplineCache>>,
}
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
#[derive(Debug)]
pub(crate) struct Prep {
/// Block members that may still be examined across the operation.
members_left: usize,
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
folded: Option<HashMap<String, String>>,
rebuilt: Rc<RefCell<SplineCache>>,
}
impl Prep {
/// Preparation that may examine at most `members` block members in all.
pub(crate) fn new(members: usize) -> Prep {
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
}
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
if let Some(b) = d.blocks.get(name) {
return Some(b);
}
let folded = self.folded.get_or_insert_with(|| {
let mut m = HashMap::with_capacity(d.blocks.len());
for k in d.blocks.keys() {
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
}
m
});
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
}
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
if let Some(s) = self.blocks.get(name) {
return s.clone();
}
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
self.blocks.insert(name.to_string(), sampled.clone());
sampled
}
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
}
}
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
let n = n as f64;
if fit { n * n * n } else { n * n }
}
}
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
/// Sample a block's finite characteristic points in block coordinates, within the per-block
/// member limit and the caller-shared `members_left`. Members that contribute no points still
/// consume traversal work; nested blocks are not entered.
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
// points (Text, Ray, …) still cost traversal (CWE-407).
for (examined, e) in block.entities.iter().enumerate() {
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
break;
}
*members_left -= 1;
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
_ => Vec::new(),
};
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
out.extend(pts.into_iter().filter(|p| p.is_finite()));
}
out.truncate(MAX_RIGID_POINTS);
out
out.into()
}
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
@@ -243,15 +297,22 @@ impl Model {
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
// weights default to 1 in eval and only excess weights are unused; a control
// spline's fit points are dropped when its control points move (write_back), and
// a fit spline is rebuilt from its parameters, not from its old control net.
let mut kept = if fit {
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
} else {
GSpline { fit: Vec::new(), ..sp.clone() }
};
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
@@ -262,16 +323,17 @@ impl Model {
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
/// Build solver parameters for supported entities, sharing block sampling, its member budget
/// and spline rebuilds with every other model built from `prep`.
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
for &h in handles {
if m.index.contains_key(&h) {
continue;
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
@@ -373,18 +435,20 @@ impl Model {
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
}
/// The spline of a reference with the defining points taken from `x`.
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
let Shape::Spline { n, fit, .. } = s.shape else { return None };
let t = self.splines.get(&s.handle)?;
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
Some(if fit {
t.refit(&pts, t.closed)
} else {
let mut sp = t.clone();
sp.control = pts;
sp
})
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
&& *at == pts
{
return Some(Rc::clone(sp));
}
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
Some(sp)
}
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
@@ -421,7 +485,7 @@ impl Model {
let e = self.ell_at(x, o, true)?;
e.frame(if at_end { e.t1 } else { e.t0 })
}
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
_ => None,
}
}
@@ -590,7 +654,8 @@ impl Model {
}
let shape = s.shape;
// A control-point spline loses its fit data once its control points move.
let spline = self.spline(x, s).map(|mut sp| {
let spline = self.spline(x, s).map(|sp| {
let mut sp = GSpline::clone(&sp);
if matches!(shape, Shape::Spline { fit: false, .. }) {
sp.fit.clear();
}
@@ -712,21 +777,21 @@ mod budget_tests {
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
let mut block = Block::new(name);
for _ in 0..lines {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
d.blocks.insert(name.into(), Arc::new(block));
}
fn insert(block: &str) -> Insert {
Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
@@ -735,13 +800,79 @@ mod budget_tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
}
}
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
}
/// The member budget is shared by every model of one preparation, and a block it already
/// sampled is reused by later Inserts without examining its members again.
#[test]
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
let mut d = Drawing::default();
add_block(&mut d, "BAR", 3);
add_block(&mut d, "BAZ", 3);
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
let mut prep = Prep::new(2);
let first = Model::build_with(&d, &[bar], &mut prep);
assert_eq!(first.rigid[&bar].len(), 4);
assert_eq!(prep.members_left, 0);
let other = Model::build_with(&d, &[baz], &mut prep);
assert!(other.rigid[&baz].is_empty());
let again = Model::build_with(&d, &[bar2], &mut prep);
assert_eq!(again.rigid[&bar2].len(), 4);
}
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
#[test]
fn block_names_resolve_ignoring_case() {
let mut d = Drawing::default();
add_block(&mut d, "Bar", 2);
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
let mut prep = Prep::new(MAX_RIGID_WORK);
let m = Model::build_with(&d, &[lower, missing], &mut prep);
assert_eq!(m.rigid[&lower].len(), 4);
assert!(m.rigid[&missing].is_empty());
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
}
/// A spline asked for again at the same defining points is not rebuilt, also from another
/// model of the same preparation; moved points rebuild it.
#[test]
fn spline_rebuilds_are_reused_at_the_same_points() {
let mut d = Drawing::default();
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
let mut prep = Prep::new(MAX_RIGID_WORK);
let a = Model::build_with(&d, &[h], &mut prep);
let b = Model::build_with(&d, &[h], &mut prep);
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
let first = a.spline(&a.x0, sa).unwrap();
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
let mut moved = a.x0.clone();
moved[1] += 0.5;
let other = a.spline(&moved, sa).unwrap();
assert!(!Rc::ptr_eq(&first, &other));
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
}
/// A control spline's template keeps no fit points and no excess weights.
#[test]
fn control_template_drops_unused_data() {
let mut d = Drawing::default();
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
sp.fit = vec![Vec2::ZERO; 10_000];
sp.fit_opts.tolerance = 0.5;
sp.weights = vec![1.0; 10_000];
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
let m = Model::build(&d, &[h]);
let t = &m.splines[&h];
assert!(t.fit.is_empty());
assert_eq!(t.weights.len(), 4);
}
}
+93 -19
View File
@@ -4,15 +4,18 @@
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
//! another copy running at the same time doesn't offer its files. The system drops the lock the
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//!
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
//! are profile settings: process-wide, not saved in the drawing.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
@@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10;
pub const MAX_SAVETIME: u32 = 600;
/// A lock file not refreshed for this long belongs to an app that is no longer running.
pub const STALE_LOCK_SECS: u64 = 5 * 60;
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
/// stopped. Older versions write only the process id and never hold one.
#[cfg(not(target_arch = "wasm32"))]
const LOCK_HELD: &str = " held";
/// How often a running app refreshes its lock file.
#[cfg(not(target_arch = "wasm32"))]
const HEARTBEAT_SECS: f64 = 60.0;
@@ -149,13 +156,19 @@ pub struct Autosaver {
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// Writes in progress; each tells whether its file was written.
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
failed: BTreeMap<u64, String>,
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
#[cfg(not(target_arch = "wasm32"))]
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// The lock file this app holds the system lock on, open for as long as the lock is wanted.
#[cfg(not(target_arch = "wasm32"))]
lock: Option<(std::path::PathBuf, std::fs::File)>,
}
impl Autosaver {
@@ -164,12 +177,15 @@ impl Autosaver {
instance: instance.into(),
docs: HashMap::new(),
busy: HashSet::new(),
failed: BTreeMap::new(),
#[cfg(not(target_arch = "wasm32"))]
running: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant::now(),
#[cfg(not(target_arch = "wasm32"))]
last_beat: None,
#[cfg(not(target_arch = "wasm32"))]
lock: None,
}
}
@@ -187,6 +203,11 @@ impl Autosaver {
self.docs.values().filter_map(|t| t.file.clone()).collect()
}
/// Why a drawing currently has no up-to-date recovery copy, for the host to show the user.
pub fn failure(&self) -> Option<&str> {
self.failed.values().next().map(String::as_str)
}
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
/// files of drawings that were saved (no unsaved changes left) or closed.
@@ -220,6 +241,7 @@ impl Autosaver {
jobs.push(Job::Delete { path });
}
*t = Tracked::default();
self.failed.remove(&st.uid);
continue;
}
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
@@ -248,6 +270,8 @@ impl Autosaver {
t.since = None;
jobs.push(Job::Write { uid: st.uid, path, doc });
}
let docs = &self.docs;
self.failed.retain(|uid, _| docs.contains_key(uid));
jobs
}
@@ -277,12 +301,14 @@ pub use native::*;
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::fs::{File, OpenOptions, TryLockError};
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name,
parse_lock_name, savefilepath, savetime,
};
/// A new instance id: the process id and the start time.
@@ -307,6 +333,28 @@ mod native {
Some(SystemTime::now().duration_since(m).unwrap_or_default())
}
/// Whether the app instance named by a lock file in `dir` may still be running: its system
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
/// file from an older version).
fn owner_alive(dir: &Path, instance: &str) -> bool {
let by_age = || instance_alive(lock_age(dir, instance));
let path = lock_path(dir, instance);
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
return by_age();
}
let Ok(file) = File::open(&path) else { return by_age() };
match file.try_lock() {
Err(TryLockError::WouldBlock) => true,
Err(TryLockError::Error(_)) => by_age(),
Ok(()) => {
let mut text = String::new();
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
!held && by_age()
}
}
}
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
/// `own`, this app's instance), oldest first.
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
@@ -316,7 +364,7 @@ mod native {
for e in entries.flatten().take(10_000) {
let name = e.file_name().to_string_lossy().to_string();
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
continue;
}
let Ok(meta) = e.metadata() else { continue };
@@ -358,7 +406,7 @@ mod native {
for n in &names {
let Some(instance) = parse_lock_name(n) else { continue };
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !instance_alive(lock_age(dir, instance)) {
if !has_files && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -425,22 +473,27 @@ mod native {
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
/// says why until a write succeeds.
pub fn reap(&mut self) {
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
self.running = running;
for (uid, h) in done {
self.busy.remove(&uid);
if !h.join().unwrap_or(false) {
self.unwritten(uid);
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
Ok(()) => {
self.failed.remove(&uid);
}
Err(e) => self.unwritten(uid, e),
}
}
}
fn unwritten(&mut self, uid: u64) {
fn unwritten(&mut self, uid: u64, why: String) {
if let Some(t) = self.docs.get_mut(&uid) {
t.written = None;
}
self.failed.insert(uid, why);
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
@@ -451,16 +504,17 @@ mod native {
Job::Write { uid, path, doc } => {
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
log::warn!("autosave: file formats are not available in this build");
self.unwritten(uid, "file formats are not available in this build".to_string());
continue;
};
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
Ok(()) => {
log::info!("autosaved {path}");
true
Ok(())
}
Err(e) => {
log::warn!("autosave {path}: {e}");
false
Err(format!("{path}: {e}"))
}
});
match spawned {
@@ -470,7 +524,7 @@ mod native {
}
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid);
self.unwritten(uid, e.to_string());
}
}
}
@@ -485,12 +539,31 @@ mod native {
}
self.last_beat = Some(now);
let dir = Path::new(dir);
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
if let Err(e) = r {
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
log::warn!("autosave: lock file in {}: {e}", dir.display());
}
}
/// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't
/// hold it there yet. Without one the file still works by its age.
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
if let Some((held, file)) = &self.lock
&& held == path
{
return file.set_modified(SystemTime::now());
}
self.lock = None;
let id = std::process::id();
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
if file.try_lock().is_err() {
return std::fs::write(path, id.to_string());
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
self.lock = Some((path.to_path_buf(), file));
Ok(())
}
/// The app is closing normally: finish writes in progress, then delete this app's autosave
/// files and its lock file.
pub fn shutdown(&mut self) {
@@ -502,6 +575,7 @@ mod native {
discard(&path);
}
self.docs.clear();
self.lock = None;
let dir = savefilepath();
if !dir.is_empty() && is_instance_id(&self.instance) {
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
+94
View File
@@ -169,6 +169,7 @@ fn recovery_offers_files_of_apps_that_stopped() {
#[test]
fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
let _turn = folder_turn();
hooks();
let dir = temp_dir("write");
let mut s = Session::new();
@@ -255,6 +256,68 @@ fn a_new_autosave_folder_is_owner_only() {
let _ = std::fs::remove_dir_all(&base);
}
/// SAVEFILEPATH is process-wide: the tests that set it take turns.
static FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn folder_turn() -> std::sync::MutexGuard<'static, ()> {
FOLDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Run autosave once in `dir`, so `a` writes a file and takes its lock, and wait for the file.
fn autosave_once(a: &mut Autosaver, s: &Session, dir: &std::path::Path) -> String {
autosave::set_savefilepath(&dir.to_string_lossy());
assert_eq!(a.save_now(s), 1);
for _ in 0..500 {
a.reap();
if !a.writing() && !a.files().is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
a.files().pop().unwrap()
}
#[test]
fn a_crashed_app_is_offered_at_once_and_a_running_one_never() {
hooks();
let _turn = folder_turn();
let dir = temp_dir("instant");
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("41-e");
let file = autosave_once(&mut a, &s, &dir);
let lock = dir.join("41-e.lock");
assert!(std::fs::metadata(&lock).is_ok());
// Running: not offered, even once its lock file looks old (a suspended laptop).
std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now() - Duration::from_secs(3600)).ok();
assert!(autosave::recoverable(&dir, "9-f").is_empty());
autosave::prune_locks(&dir);
assert!(lock.exists(), "a running app's lock stays");
// Gone without closing, as in a crash: the file is fresh, and it is offered right away.
std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now()).ok();
drop(a);
let found = autosave::recoverable(&dir, "9-f");
assert_eq!(found.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(), vec![file.as_str()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_normal_exit_removes_the_lock_file() {
hooks();
let _turn = folder_turn();
let dir = temp_dir("exit");
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("42-e");
autosave_once(&mut a, &s, &dir);
assert!(dir.join("42-e.lock").exists());
a.shutdown();
assert!(!dir.join("42-e.lock").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_autosave_is_tried_again_without_another_change() {
hooks();
@@ -283,6 +346,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(rearmed, "a failed autosave must be tried again");
assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure());
std::fs::remove_file(&out).unwrap();
let jobs = a.plan(&s, 180.0, 1, &dir);
@@ -303,6 +367,36 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(a.next_due(1e4, 1), None);
assert_eq!(a.failure(), None, "a write that worked clears the failure");
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() {
hooks();
let root = temp_dir("failed-close");
let out = root.join("out");
std::fs::write(&out, b"not a folder").unwrap();
let dir = out.to_string_lossy().to_string();
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("32-e");
a.plan(&s, 0.0, 1, &dir);
let jobs = a.plan(&s, 60.0, 1, &dir);
a.perform(jobs);
for _ in 0..500 {
a.reap();
if a.failure().is_some() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(a.failure().is_some());
let i = s.active;
s.execute("close", &json!({ "index": i })).unwrap();
a.plan(&s, 120.0, 1, &dir);
assert_eq!(a.failure(), None);
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
+58
View File
@@ -32,6 +32,15 @@ pub fn load_prefs(json: &str) {
}
}
/// What the autosave failure notice says (see [`failure_notice`]).
#[derive(Clone, Debug, PartialEq)]
pub struct AutosaveNotice {
pub message: String,
/// The user closed it. The failure stays recorded so the notice doesn't come back on every
/// retry; it does for the next failure after a write has worked.
pub dismissed: bool,
}
/// What the GPU error notice says (see [`gpu_notice`]).
#[derive(Clone, Debug, PartialEq)]
pub struct GpuNotice {
@@ -90,6 +99,24 @@ impl CadApp {
if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) {
_ctx.request_repaint_after(wait);
}
#[cfg(not(target_arch = "wasm32"))]
self.note_autosave_failure(_ctx);
}
/// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the
/// command line and shown until it is dismissed or a write works.
#[cfg(not(target_arch = "wasm32"))]
fn note_autosave_failure(&mut self, ctx: &egui::Context) {
let failure = self.autosave.as_ref().and_then(|s| s.failure()).map(str::to_string);
match (failure, &mut self.autosave_notice) {
(None, notice) => *notice = None,
(Some(message), Some(n)) => n.message = message,
(Some(message), notice) => {
self.session.echo(format!("Autosave failed: {message}"));
*notice = Some(AutosaveNotice { message, dismissed: false });
ctx.request_repaint();
}
}
}
/// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the
@@ -242,6 +269,37 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
}
}
/// The notice that autosave isn't working, so unsaved changes have no recovery copy. It doesn't
/// block the drawing: the user needs to be able to save.
pub fn failure_notice(app: &mut CadApp, ctx: &egui::Context) {
let Some(n) = app.autosave_notice.as_ref().filter(|n| !n.dismissed) else { return };
let t = Tokens::get();
let mut dismiss = false;
egui::Window::new("Autosave failed")
.id(egui::Id::unique("cc_autosave_notice"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
.show(ctx, |ui| {
ui.set_width(440.0);
ui.label("CADCraft could not write a recovery copy, so recent changes would be lost if the app stopped now.");
ui.label(RichText::new("Save your work. Autosave tries again after the SAVETIME interval; its folder is in Options.").strong());
ui.add_space(4.0);
ui.collapsing("Details", |ui| {
ui.label(RichText::new(&n.message).color(t.text_dim).monospace());
});
// A row just as tall as the button (a plain right-to-left layout takes the rest).
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Dismiss").clicked() {
dismiss = true;
}
});
});
if dismiss && let Some(n) = &mut app.autosave_notice {
n.dismissed = true;
}
}
#[cfg(test)]
mod tests {
#[test]
+4
View File
@@ -305,6 +305,8 @@ pub struct CadApp {
pub gpu_faults: std::sync::Arc<gpu::GpuFaults>,
/// The notice shown after one (see [`autosave::gpu_notice`]).
pub gpu_notice: Option<autosave::GpuNotice>,
/// The notice shown while autosave fails (see [`autosave::failure_notice`]).
pub autosave_notice: Option<autosave::AutosaveNotice>,
/// The Command Aliases dialog while open ([`aliasdlg`]).
pub alias_dlg: Option<aliasdlg::AliasDlgState>,
/// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]).
@@ -364,6 +366,7 @@ impl CadApp {
recovery: autosave::Recovery::default(),
gpu_faults: std::sync::Arc::default(),
gpu_notice: None,
autosave_notice: None,
alias_dlg: None,
hatch_dlg: None,
bedit: bedit::State::default(),
@@ -903,6 +906,7 @@ impl CadApp {
save_loss::prompt(self, ui.ctx());
autosave::show(self, ui.ctx());
autosave::gpu_notice(self, ui.ctx());
autosave::failure_notice(self, ui.ctx());
self.frame_ms = now_ms() - t0;
}
+82
View File
@@ -0,0 +1,82 @@
//! A failed autosave reaches the user: a notice and one command-line message, gone once a write
//! works. SAVEFILEPATH is process-wide, so everything runs in one test.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::Drawing;
use cadcraft_ui_egui::{CadApp, Services};
fn wait_for(app: &mut CadApp, ctx: &egui::Context, done: impl Fn(&CadApp) -> bool) {
for _ in 0..500 {
app.autosave_tick(ctx);
if done(app) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
/// The text the notice paints (the second frame: a new window sizes itself in the first).
fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String {
fn collect(shape: &egui::Shape, text: &mut String) {
match shape {
egui::Shape::Text(t) => text.push_str(t.galley.text()),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)),
_ => {}
}
}
let mut text = String::new();
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::autosave::failure_notice(app, ui.ctx()));
out.textures_delta.clear(); // no renderer here
text.clear();
out.shapes.iter().for_each(|s| collect(&s.shape, &mut text));
}
text
}
#[test]
fn a_failed_autosave_is_shown_until_a_write_works() {
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
let root = std::env::temp_dir().join(format!("cadcraft-autosave-failure-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
// The folder can't be created while a file holds its name.
let dir = root.join("out");
std::fs::write(&dir, b"not a folder").unwrap();
cadcraft_engine::autosave::set_savefilepath(&dir.to_string_lossy());
let mut app = CadApp::new(Session::new(), Services::default());
app.enable_autosave(&dir);
let ctx = egui::Context::default();
app.autosave_tick(&ctx);
assert!(app.autosave_notice.is_none(), "nothing failed yet");
app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
app.gpu_faults.error("forces an autosave now".into());
app.gpu_fault_tick(&ctx);
wait_for(&mut app, &ctx, |a| a.autosave_notice.is_some());
let n = app.autosave_notice.clone().expect("the failure reaches the notice");
assert!(!n.dismissed && n.message.contains("out"), "{n:?}");
let said = |app: &CadApp| app.session.log.iter().filter(|l| l.starts_with("Autosave failed:")).count();
assert_eq!(said(&app), 1);
assert!(painted_text(&mut app, &ctx).contains("could not write a recovery copy"));
// Dismissed, it stays away through retries and isn't repeated at the command line.
app.autosave_notice.as_mut().unwrap().dismissed = true;
app.autosave_tick(&ctx);
assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed));
assert!(painted_text(&mut app, &ctx).is_empty());
assert_eq!(said(&app), 1);
// The folder comes back: the next write clears it.
std::fs::remove_file(&dir).unwrap();
app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap();
app.gpu_faults.error("forces an autosave now".into());
app.gpu_fault_tick(&ctx);
wait_for(&mut app, &ctx, |a| a.autosave_notice.is_none());
assert!(app.autosave_notice.is_none(), "a write that worked ends the notice");
app.shutdown_autosave();
let _ = std::fs::remove_dir_all(&root);
}