mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 08:52:05 -06:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6dde7d6e | ||
|
|
d180b28ef6 | ||
|
|
2e9761249a | ||
|
|
c956baa349 | ||
|
|
997f152cea | ||
|
|
0d3b3d21c0 | ||
|
|
4482316306 | ||
|
|
f6d84e28f3 | ||
|
|
806654aedf | ||
|
|
6c4984f5ad | ||
|
|
fdfe4d95a8 |
Generated
+1
@@ -691,6 +691,7 @@ name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
|
||||
|
||||
Maintainers read reports as they arrive, but there is no fixed response time yet.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
|
||||
|
||||
## What counts
|
||||
|
||||
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
|
||||
|
||||
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
|
||||
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
|
||||
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
|
||||
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
|
||||
- **Updates.** A release must not be replaceable by someone other than the project.
|
||||
|
||||
## Known limits
|
||||
|
||||
These are known and being worked on, so they don't need a report:
|
||||
|
||||
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
|
||||
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
|
||||
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
|
||||
- The web build has no autosave yet.
|
||||
|
||||
## What stays on your machine
|
||||
|
||||
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
|
||||
@@ -1,6 +1,11 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
|
||||
// reported as an error, not panic before anything is printed.
|
||||
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save,
|
||||
//! not the app. The worker setting is process-wide, so everything runs in one test.
|
||||
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use cadcraft_io::dwg_worker;
|
||||
|
||||
#[test]
|
||||
fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
|
||||
let drawing = cadcraft_engine::sample::bracket();
|
||||
let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
|
||||
dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]);
|
||||
let written = cadcraft_io::write(&drawing, "a.dwg").unwrap();
|
||||
assert_eq!(written.get(..6), in_process.get(..6), "the same version of file");
|
||||
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
|
||||
assert_eq!(read.model.len(), drawing.model.len());
|
||||
|
||||
// A decode error comes back as that error, not as the worker stopping.
|
||||
let mut bad = in_process.clone();
|
||||
bad.truncate(64);
|
||||
let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string();
|
||||
assert!(!e.contains("worker"), "{e}");
|
||||
|
||||
// The worker aborts as the third-party code can. This process goes on.
|
||||
let e = dwg_worker::crash_worker_for_test().unwrap_err();
|
||||
assert!(e.contains("worker stopped"), "{e}");
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker");
|
||||
|
||||
// No worker to start: the work happens here, as without one.
|
||||
dwg_worker::use_worker("no-such-program-here".into(), Vec::new());
|
||||
assert!(cadcraft_io::read(&written, "a.dwg").is_ok());
|
||||
}
|
||||
@@ -86,7 +86,7 @@ impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
self.cad.guarded_logic(ctx);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
@@ -116,7 +116,7 @@ impl eframe::App for App {
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.cad.guarded_ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
@@ -258,6 +258,11 @@ fn version_string() -> String {
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// A copy of this program started to read or write a DWG file does only that.
|
||||
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
cadcraft_io::dwg_worker::use_own_worker();
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
@@ -289,6 +294,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
install_io();
|
||||
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
|
||||
if let Some(dir) = &vars.config_dir {
|
||||
cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR));
|
||||
}
|
||||
if let Some(name) = &vars.font_alt {
|
||||
cadcraft_engine::fonts::ttf::set_font_alt(name);
|
||||
}
|
||||
|
||||
@@ -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,37 @@ 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());
|
||||
// Its residuals already hold (the other object is missing), so solving does not iterate
|
||||
// and is not refused; an unsatisfied system of this size is (see below).
|
||||
assert!(comp.run(None).is_ok_and(|o| o.converged));
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ mod lm;
|
||||
mod model;
|
||||
mod residual;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
@@ -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 {
|
||||
@@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool {
|
||||
d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Highest numbered default name [`next_name`] tries.
|
||||
const MAX_NAME_SUFFIX: u32 = 100_000;
|
||||
|
||||
/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …).
|
||||
pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String {
|
||||
let prefix = kind.name_prefix();
|
||||
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
|
||||
// Collect the suffixes in use in one pass, then take the first free one: checking every
|
||||
// candidate against every name is quadratic in the number of names (CWE-407). Only a
|
||||
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
|
||||
let taken: HashSet<u32> = d
|
||||
.parametric
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(d.constraints.iter().map(|c| c.name.as_str()))
|
||||
.filter_map(|n| n.strip_prefix(prefix))
|
||||
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect();
|
||||
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
|
||||
}
|
||||
|
||||
/// Next free constraint id.
|
||||
@@ -251,12 +272,52 @@ 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`]). A spline none of whose points
|
||||
/// is free never moves, so its one rebuild is reused by every evaluation.
|
||||
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;
|
||||
let rebuilds = if own == 0.0 { 1.0 } else { 2.0 * own + TRIAL_EVALS + 1.0 };
|
||||
rebuilds * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
/// Do the residuals hold at `x`, as [`lm::solve`] decides before its first iteration?
|
||||
fn holds(&self, x: &[f64], skip: Option<usize>) -> bool {
|
||||
let mut out = Vec::new();
|
||||
self.eval(x, &mut out, skip);
|
||||
out.is_empty() || lm::max_abs(&out) <= self.tol
|
||||
}
|
||||
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). A
|
||||
// system that already holds is not iterated (`lm::solve` stops after one evaluation), so
|
||||
// it is not refused: a large component left as it is must not stop the rest of the
|
||||
// drawing from being solved.
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK && !self.holds(&self.model.x0, skip) {
|
||||
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 +471,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 +701,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 +714,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 +731,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);
|
||||
@@ -730,6 +801,11 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
&& nb.rows > 0
|
||||
{
|
||||
let free = comp.free(None);
|
||||
// `run` lets a system that already holds through without its work budget; this Jacobian
|
||||
// is built regardless, so it keeps that budget (CWE-407).
|
||||
if comp.eval_work(&free, None) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let mut ordered: Vec<&Built> = comp.built.iter().filter(|b| b.id != id).collect();
|
||||
ordered.push(nb);
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| {
|
||||
@@ -752,6 +828,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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,3 +489,89 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default name is the first free numbered one, found in one pass over many names in use.
|
||||
#[test]
|
||||
fn next_name_skips_names_in_use() {
|
||||
let mut d = Drawing::default();
|
||||
assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1");
|
||||
for i in (1..=20_000).filter(|i| *i != 15_000) {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000");
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() });
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001");
|
||||
// A differently spelled suffix does not occupy the plain name.
|
||||
let mut d = Drawing::default();
|
||||
for name in ["d01", "d+1", "d1x", "D1"] {
|
||||
d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() });
|
||||
}
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1");
|
||||
d.constraints.push(Constraint {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Distance(DistAxis::Aligned),
|
||||
refs: Vec::new(),
|
||||
name: "d1".into(),
|
||||
expr: "1".into(),
|
||||
});
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
|
||||
}
|
||||
|
||||
/// A straight open fit spline through `n` points from `from` in steps of `step`.
|
||||
fn straight_fit_spline(d: &mut Drawing, n: usize, from: (f64, f64), step: (f64, f64)) -> Handle {
|
||||
let pts: Vec<Vec2> = (0..n).map(|i| Vec2::new(from.0 + step.0 * i as f64, from.1 + step.1 * i as f64)).collect();
|
||||
add_e(d, EntityKind::Spline(cadcraft_geom::Spline::fit_with(&pts, false, Default::default())))
|
||||
}
|
||||
|
||||
fn push_con(d: &mut Drawing, kind: ConstraintKind, refs: &[GeomRef]) {
|
||||
let id = d.constraints.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||
d.constraints.push(Constraint { id, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
/// A component that already holds is not iterated, so its size does not keep the rest of the
|
||||
/// drawing from being solved: a Tangent on a 200-point fit spline that is already satisfied
|
||||
/// must not make re-solving an unrelated Horizontal fail as "too large".
|
||||
#[test]
|
||||
fn satisfied_large_spline_component_does_not_block_solve() {
|
||||
let mut d = Drawing::default();
|
||||
let sp = straight_fit_spline(&mut d, 200, (0.0, 0.0), (1.0, 0.0));
|
||||
let touching = line(&mut d, (199.0, 0.0), (220.0, 0.0));
|
||||
push_con(&mut d, ConstraintKind::Tangent, &[w(sp), w(touching)]);
|
||||
let other = line(&mut d, (0.0, 50.0), (10.0, 51.0));
|
||||
push_con(&mut d, ConstraintKind::Horizontal, &[w(other)]);
|
||||
let r = solve(&mut d, &SolveOptions::default());
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let (a, b) = ln(&d, other);
|
||||
assert!((a.y - b.y).abs() < 1e-6);
|
||||
assert_eq!(ln(&d, touching), (Vec2::new(199.0, 0.0), Vec2::new(220.0, 0.0)));
|
||||
}
|
||||
|
||||
/// Fixed splines are not rebuilt while solving (their points never move, so the one rebuild is
|
||||
/// reused): two large fixed fit splines with a free line tangent to both still solve.
|
||||
#[test]
|
||||
fn fixed_large_splines_do_not_count_as_rebuilt_every_evaluation() {
|
||||
let mut d = Drawing::default();
|
||||
let a = straight_fit_spline(&mut d, 450, (0.0, 0.0), (1.0, 0.0));
|
||||
let b = straight_fit_spline(&mut d, 450, (1449.0, 0.0), (-1.0, 0.0));
|
||||
let l = line(&mut d, (449.0, 0.0), (1000.0, 0.5));
|
||||
push_con(&mut d, ConstraintKind::Fix, &[w(a)]);
|
||||
push_con(&mut d, ConstraintKind::Fix, &[w(b)]);
|
||||
push_con(&mut d, ConstraintKind::Tangent, &[w(a), w(l)]);
|
||||
push_con(&mut d, ConstraintKind::Tangent, &[w(b), w(l)]);
|
||||
let r = solve(&mut d, &SolveOptions::default());
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let (p, q) = ln(&d, l);
|
||||
assert!(p.y.abs() < 1e-6 && q.y.abs() < 1e-6, "{p:?} {q:?}");
|
||||
}
|
||||
|
||||
/// Adding a constraint still checks redundancy within the work budget when the system already
|
||||
/// holds: an already tangent line on a free 400-point fit spline is refused, not differentiated.
|
||||
#[test]
|
||||
fn add_on_satisfied_large_spline_keeps_redundancy_budget() {
|
||||
let mut d = Drawing::default();
|
||||
let sp = straight_fit_spline(&mut d, 400, (0.0, 0.0), (1.0, 0.0));
|
||||
let l = line(&mut d, (399.0, 0.0), (420.0, 0.0));
|
||||
let r = add(&mut d, con(ConstraintKind::Tangent, &[w(sp), w(l)]), &SolveOptions::default());
|
||||
assert!(matches!(r, Err(SolveError::TooLarge)), "{r:?}");
|
||||
assert!(d.constraints.is_empty());
|
||||
}
|
||||
|
||||
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
|
||||
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
|
||||
///
|
||||
/// Two linear passes: searching from every segment separately is quadratic in a run of
|
||||
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
|
||||
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
|
||||
return (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
};
|
||||
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
|
||||
let mut cur = Vec2::X;
|
||||
for k in 0..n {
|
||||
let i = (last + n - k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(a) = ahead.get_mut(i) {
|
||||
*a = cur;
|
||||
}
|
||||
}
|
||||
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
|
||||
for k in 0..n {
|
||||
let i = (first + k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(b) = behind.get_mut(i) {
|
||||
*b = cur;
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
@@ -268,16 +303,11 @@ impl MLine {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// A degenerate segment takes its neighbour's direction.
|
||||
let dir = |i: usize| -> Vec2 {
|
||||
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
|
||||
};
|
||||
let dir_back = |i: usize| -> Vec2 {
|
||||
(0..nseg)
|
||||
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
|
||||
.find(|d| *d != Vec2::ZERO)
|
||||
.unwrap_or(Vec2::X)
|
||||
};
|
||||
// A degenerate segment takes its neighbour's direction: the first usable one at or after
|
||||
// it (`ahead`), or at or before it (`behind`), wrapping around.
|
||||
let (ahead, behind) = usable_directions(&dirs);
|
||||
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
@@ -426,3 +456,61 @@ impl crate::Drawing {
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The nearest usable directions found by searching from each segment, as miters did before.
|
||||
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
(ahead.collect(), behind.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_directions_match_a_search_from_each_segment() {
|
||||
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
|
||||
for dirs in [
|
||||
vec![],
|
||||
vec![z],
|
||||
vec![a],
|
||||
vec![z, z, z],
|
||||
vec![a, z, z, b],
|
||||
vec![z, a, z, b, z],
|
||||
vec![z, z, c],
|
||||
vec![c, z, z],
|
||||
vec![a, b, c],
|
||||
vec![z, a, z, z, b, c, z],
|
||||
] {
|
||||
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
|
||||
// Every vertex of a run on one point mitres between the segment before the run and the one after.
|
||||
let style = MLineStyle::default();
|
||||
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
|
||||
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
|
||||
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
|
||||
let (expected, got) = (corner.miters(), run.miters());
|
||||
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
|
||||
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_coincident_vertices_takes_linear_time() {
|
||||
// 200 000 vertices on one point but the last: the miters must not search the run once per
|
||||
// vertex (quadratic, minutes) to find the one usable direction.
|
||||
let mut pts = vec![Vec2::ZERO; 200_000];
|
||||
pts.push(Vec2::new(1.0, 0.0));
|
||||
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
|
||||
let t = std::time::Instant::now();
|
||||
let miters = m.miters();
|
||||
assert_eq!(miters.len(), pts.len());
|
||||
assert!(miters.iter().all(|v| v.is_finite()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ use super::HatchLoop;
|
||||
/// Most loops one region has (hostile files and edits).
|
||||
pub const MAX_REGION_LOOPS: usize = 10_000;
|
||||
|
||||
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
|
||||
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// Closed loops with bulges; `outer` false for islands.
|
||||
@@ -76,20 +79,49 @@ impl Region {
|
||||
}
|
||||
|
||||
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
|
||||
/// an outer loop, odd an island.
|
||||
pub fn renest(&mut self) {
|
||||
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
|
||||
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
|
||||
#[must_use]
|
||||
pub fn renest(&mut self) -> bool {
|
||||
let polys = self.tessellate(self.fine_tol());
|
||||
let depth: Vec<usize> = polys
|
||||
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
|
||||
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
|
||||
// such a polygon need not be closed), so its loop is always tested.
|
||||
let boxes: Vec<Option<Bounds2>> = polys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let Some(p) = probe(l) else { return 0 };
|
||||
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
|
||||
.map(|l| {
|
||||
let b = Bounds2::from_points(l.iter().copied());
|
||||
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
|
||||
})
|
||||
.collect();
|
||||
let mut visits = 0usize;
|
||||
let mut depth = Vec::with_capacity(polys.len());
|
||||
for (i, l) in polys.iter().enumerate() {
|
||||
let Some(p) = probe(l) else {
|
||||
depth.push(0);
|
||||
continue;
|
||||
};
|
||||
let mut d = 0;
|
||||
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
|
||||
if j == i || b.is_some_and(|b| !b.contains(p)) {
|
||||
continue;
|
||||
}
|
||||
// Each loop against every other is loops x vertices: bounded so a region of many
|
||||
// large nested loops cannot hold the thread (CWE-407).
|
||||
visits = visits.saturating_add(o.len());
|
||||
if visits > MAX_RENEST_EDGE_VISITS {
|
||||
return false;
|
||||
}
|
||||
if point_in_polygon(o, p) {
|
||||
d += 1;
|
||||
}
|
||||
}
|
||||
depth.push(d);
|
||||
}
|
||||
for (l, d) in self.loops.iter_mut().zip(depth) {
|
||||
l.outer = d % 2 == 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
|
||||
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
|
||||
// `n` vertices along the bottom edge, then the other three corners.
|
||||
let mut vertices: Vec<PolyVertex> =
|
||||
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
|
||||
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
|
||||
HatchLoop { vertices, outer, sources: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_sets_depth_parity_and_skips_loops_far_away() {
|
||||
// Three nested squares, and one beside them.
|
||||
let mut r =
|
||||
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
|
||||
assert!(r.renest());
|
||||
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
|
||||
// Concentric loops of many vertices each: each probe is inside the box of every larger
|
||||
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
|
||||
let loops = 300;
|
||||
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
|
||||
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
|
||||
let before = r.clone();
|
||||
assert!(!r.renest());
|
||||
assert_eq!(r, before, "flags left as they were");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
bounds_of(d, e, depth, false, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
bounds_of(d, e, 0, true, &mut HashMap::new())
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
/// The bounds of block contents measured so far, kept across the entities of one unchanged
|
||||
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
|
||||
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
|
||||
/// entity would measure a block again for every reference to it, R references to a block of B
|
||||
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
|
||||
#[derive(Default)]
|
||||
pub struct BlockBoundsCache<'a> {
|
||||
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
|
||||
drawn: HashMap<(&'a str, usize), Bounds2>,
|
||||
/// The same with those contents counted ([`extent_bounds`]).
|
||||
extents: HashMap<(&'a str, usize), Bounds2>,
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false, &mut cache.drawn)
|
||||
}
|
||||
|
||||
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
|
||||
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true, &mut cache.extents)
|
||||
}
|
||||
|
||||
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
bounds_in(d, e, depth, blocks, off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -442,4 +465,53 @@ mod tests {
|
||||
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
|
||||
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
|
||||
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
|
||||
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
|
||||
e.common.layer = layer.into();
|
||||
e
|
||||
};
|
||||
let ins = |block: &str, at: f64| {
|
||||
Entity::new(
|
||||
crate::Handle(2),
|
||||
EntityKind::Insert(crate::Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::new(at, at, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.ensure_layer("OFF");
|
||||
if let Some(l) = d.layer_mut("OFF") {
|
||||
l.on = false;
|
||||
}
|
||||
// A block with a part on a layer turned off (left out when drawn, counted in extents),
|
||||
// and a block that nests it.
|
||||
let mut b = crate::Block::new("B");
|
||||
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
|
||||
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(b));
|
||||
let mut n = crate::Block::new("N");
|
||||
n.entities.push(ins("B", 10.0));
|
||||
d.blocks.insert("N".into(), std::sync::Arc::new(n));
|
||||
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
|
||||
// One cache for both kinds of measure, interleaved: neither may answer for the other.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
for e in &ents {
|
||||
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
|
||||
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
|
||||
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
|
||||
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
|
||||
assert!(extents.width() > drawn.width());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
@@ -383,10 +383,13 @@ impl Drawing {
|
||||
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
|
||||
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
||||
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
||||
// One block-bounds cache for the whole space: every reference to a block reuses its
|
||||
// measured contents instead of measuring the definition again.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
if let Some(store) = self.space(space) {
|
||||
for e in store.iter() {
|
||||
if self.in_extents(e) {
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
b = b.union(&extent_bounds_cached(self, e, &mut cache));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
|
||||
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
|
||||
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
|
||||
|
||||
+25
-4
@@ -19,10 +19,8 @@
|
||||
//! crashing.
|
||||
//!
|
||||
//! These bound the input- and output-size variants. A small file that declares a huge internal
|
||||
//! table or section can still make the dependency allocate before any size is known; fully fixing
|
||||
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
|
||||
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
|
||||
//! keep the blast radius bounded in the meantime.
|
||||
//! table or section can still make the dependency allocate before any size is known, so the app
|
||||
//! runs both directions in a child process ([`worker`]): what that kills is the child.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -64,6 +62,11 @@ fn mb(n: u64) -> String {
|
||||
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
use std::io::{Cursor, Write};
|
||||
@@ -930,6 +933,24 @@ fn unsupported(version: &str) -> String {
|
||||
#[cfg(no_dwg_bridge)]
|
||||
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
|
||||
|
||||
/// There's no worker without a bridge.
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
None
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn use_own_worker() {}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
dwg_to_dxf_noted(bytes, limits)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! The DWG reader and writer in a child process.
|
||||
//!
|
||||
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
|
||||
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
|
||||
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
|
||||
//! that reads one request from standard input and writes one reply to standard output. A file that
|
||||
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
|
||||
//!
|
||||
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
|
||||
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
|
||||
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
|
||||
//!
|
||||
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
|
||||
//! untrusted input.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::Limits;
|
||||
|
||||
/// The argument that makes the app a worker.
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
/// Bumped when the messages change: an app updated while running may start a newer copy.
|
||||
const PROTOCOL: u8 = 1;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, _)| dwg),
|
||||
None => super::dxf_to_dwg_as(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
r.push(u8::try_from(version.len()).unwrap_or(0));
|
||||
r.extend(version.as_bytes());
|
||||
r
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())),
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_reports_a_decode_failure_as_a_reply() {
|
||||
let mut request = self::request(DECODE, Limits::DEFAULT, "");
|
||||
request.extend(b"AC1032 not really a drawing");
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(serve(&request[..], &mut out), 0);
|
||||
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
|
||||
}
|
||||
}
|
||||
+140
-21
@@ -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,21 @@ 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>,
|
||||
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
|
||||
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
|
||||
/// locks don't work and only the file's age counts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -164,12 +179,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"))]
|
||||
locks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +205,11 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
|
||||
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
|
||||
self.failed.iter().map(|(uid, why)| (*uid, why.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 +243,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 +272,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 +303,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 +335,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 +366,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 +408,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));
|
||||
}
|
||||
}
|
||||
@@ -423,24 +473,60 @@ mod native {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
|
||||
/// another [`Autosaver::tick`] to learn whether it failed.
|
||||
pub fn results_pending(&self) -> bool {
|
||||
!self.running.is_empty()
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
self.settle(uid, h);
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
|
||||
self.busy.remove(&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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write every drawing with changes not yet autosaved and wait until it is done, because
|
||||
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
|
||||
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
|
||||
/// how many drawings were written.
|
||||
pub fn emergency_save(&mut self, s: &Session) -> usize {
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let dir = savefilepath();
|
||||
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
|
||||
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
|
||||
self.perform(jobs);
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&dir, now);
|
||||
for (uid, h) in std::mem::take(&mut self.running) {
|
||||
self.settle(uid, h);
|
||||
}
|
||||
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
|
||||
}
|
||||
|
||||
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 +537,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 +557,7 @@ mod native {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
self.unwritten(uid, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,12 +572,40 @@ 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 this app's lock files, and start one at `path` if there isn't one yet, taking
|
||||
/// the system lock on it if it can. Without that lock the file still works by its age.
|
||||
/// Only trouble at `path` is an error: an older folder may be gone by now.
|
||||
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
let id = std::process::id();
|
||||
let mut current = None;
|
||||
for (held, file) in &self.locks {
|
||||
let refreshed = match file {
|
||||
Some(file) => file.set_modified(SystemTime::now()),
|
||||
None => std::fs::write(held, id.to_string()),
|
||||
};
|
||||
if held == path {
|
||||
current = Some(refreshed);
|
||||
}
|
||||
}
|
||||
if let Some(refreshed) = current {
|
||||
return refreshed;
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return std::fs::write(path, id.to_string());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.locks.push((path.to_path_buf(), Some(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 +617,10 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
for (path, file) in self.locks.drain(..) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
let dir = savefilepath();
|
||||
if !dir.is_empty() && is_instance_id(&self.instance) {
|
||||
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
|
||||
|
||||
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
|
||||
}
|
||||
loops.retain(|l| l.vertices.len() >= 2);
|
||||
let mut r = Region { loops };
|
||||
r.renest();
|
||||
if !r.renest() {
|
||||
return Err(other("These regions are too complex to combine."));
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
|
||||
use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached};
|
||||
use cadcraft_geom::Bounds2;
|
||||
use rstar::primitives::{GeomWithData, Rectangle};
|
||||
use rstar::{AABB, RTree};
|
||||
@@ -44,6 +44,8 @@ struct ChunkIx {
|
||||
impl ChunkIx {
|
||||
fn build(d: &Drawing, key: usize, items: &[Arc<Entity>]) -> ChunkIx {
|
||||
let mut infinite = Vec::new();
|
||||
// One block-bounds cache for the chunk: references to the same block measure it once.
|
||||
let mut cache = BlockBoundsCache::default();
|
||||
let bounds = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -51,7 +53,7 @@ impl ChunkIx {
|
||||
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
|
||||
infinite.push(u32::try_from(i).unwrap_or(u32::MAX));
|
||||
}
|
||||
entity_bounds(d, e, 0)
|
||||
entity_bounds_cached(d, e, 0, &mut cache)
|
||||
})
|
||||
.collect();
|
||||
let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -200,7 +202,7 @@ impl SpatialIndex {
|
||||
let (c, l) = self.locate(i)?;
|
||||
c.ents.get(l)
|
||||
}
|
||||
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
|
||||
/// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them).
|
||||
pub fn bounds(&self, i: u32) -> Bounds2 {
|
||||
self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY)
|
||||
}
|
||||
|
||||
@@ -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,108 @@ 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 an_emergency_save_finishes_before_it_returns_even_with_savetime_off() {
|
||||
hooks();
|
||||
let _turn = folder_turn();
|
||||
let dir = temp_dir("emergency");
|
||||
autosave::set_savefilepath(&dir.to_string_lossy());
|
||||
let before = autosave::savetime();
|
||||
autosave::set_savetime(0);
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("43-e");
|
||||
assert_eq!(a.save_now(&s), 0, "an ordinary save honours SAVETIME 0");
|
||||
assert_eq!(a.emergency_save(&s), 1);
|
||||
autosave::set_savetime(before);
|
||||
let file = a.files().pop().unwrap();
|
||||
assert!(std::fs::read(&file).unwrap().ends_with(b"EOF\n"), "the file is whole on return");
|
||||
assert_eq!(a.emergency_save(&s), 0, "nothing changed since");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_autosave_folder_keeps_the_old_folder_locked() {
|
||||
hooks();
|
||||
let _turn = folder_turn();
|
||||
let (old, new) = (temp_dir("moved-old"), temp_dir("moved-new"));
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("44-e");
|
||||
autosave_once(&mut a, &s, &old);
|
||||
autosave::set_savefilepath(&new.to_string_lossy());
|
||||
draw(&mut s);
|
||||
assert_eq!(a.emergency_save(&s), 1);
|
||||
assert!(new.join("44-e.lock").exists());
|
||||
assert!(autosave::recoverable(&old, "9-f").is_empty(), "this app is still running");
|
||||
a.shutdown();
|
||||
assert!(!old.join("44-e.lock").exists() && !new.join("44-e.lock").exists());
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
let _ = std::fs::remove_dir_all(&new);
|
||||
}
|
||||
|
||||
#[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 +386,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.failures().next().is_some_and(|(_, why)| why.contains("out")));
|
||||
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
let jobs = a.plan(&s, 180.0, 1, &dir);
|
||||
@@ -303,6 +407,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.failures().next(), 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.failures().next().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(a.failures().next().is_some());
|
||||
let i = s.active;
|
||||
s.execute("close", &json!({ "index": i })).unwrap();
|
||||
a.plan(&s, 120.0, 1, &dir);
|
||||
assert_eq!(a.failures().next(), None);
|
||||
a.shutdown();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ pub enum IoError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IoError>;
|
||||
|
||||
/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`).
|
||||
pub mod dwg_worker {
|
||||
pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
pub use cadcraft_dwg::{crash_worker_for_test, use_worker};
|
||||
}
|
||||
|
||||
fn ext(name: impl AsRef<Path>) -> String {
|
||||
name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
@@ -64,7 +71,7 @@ fn ext(name: impl AsRef<Path>) -> String {
|
||||
pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
|
||||
let name = name.as_ref();
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let (dxf, notes) = cadcraft_dwg::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
|
||||
d.read_notes = notes;
|
||||
return Ok(d);
|
||||
@@ -106,7 +113,7 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
|
||||
Some(Format::Dwg) if !opts.binary => {
|
||||
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
|
||||
cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
_ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))),
|
||||
_ => write_framed(d, name, None),
|
||||
|
||||
@@ -32,6 +32,18 @@ pub fn load_prefs(json: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the autosave failure notice says (see [`failure_notice`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AutosaveNotice {
|
||||
/// The drawings it has told the user about. Another drawing's failure is news, even after
|
||||
/// the notice was dismissed.
|
||||
pub reported: std::collections::BTreeSet<u64>,
|
||||
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 +102,40 @@ 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"))]
|
||||
{
|
||||
// A write that fails while the app is idle must still reach the notice.
|
||||
if self.autosave.as_ref().is_some_and(cadcraft_engine::autosave::Autosaver::results_pending) {
|
||||
_ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
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 failures: Vec<(u64, String)> = self.autosave.iter().flat_map(|s| s.failures()).map(|(uid, why)| (uid, why.to_string())).collect();
|
||||
if failures.is_empty() {
|
||||
self.autosave_notice = None;
|
||||
return;
|
||||
}
|
||||
let notice =
|
||||
self.autosave_notice.get_or_insert_with(|| AutosaveNotice { reported: Default::default(), message: String::new(), dismissed: false });
|
||||
notice.reported.retain(|uid| failures.iter().any(|(failed, _)| failed == uid));
|
||||
for (uid, message) in &failures {
|
||||
if notice.reported.insert(*uid) {
|
||||
self.session.echo(format!("Autosave failed: {message}"));
|
||||
notice.message.clone_from(message);
|
||||
notice.dismissed = false;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
// The drawing it names recovered while another still fails.
|
||||
if let Some((_, message)) = failures.first().filter(|_| !failures.iter().any(|(_, m)| *m == notice.message)) {
|
||||
notice.message.clone_from(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the
|
||||
@@ -107,8 +153,9 @@ impl CadApp {
|
||||
|
||||
/// The app closes normally: this app's autosave files are not needed any more.
|
||||
pub fn shutdown_autosave(&mut self) {
|
||||
// After the interface gave up, the recovery copies are all that's left of the work.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(s) = &mut self.autosave {
|
||||
if let Some(s) = self.autosave.as_mut().filter(|_| !self.fault.broken) {
|
||||
s.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -116,7 +163,7 @@ impl CadApp {
|
||||
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch.
|
||||
fn utc(secs: u64) -> String {
|
||||
pub(crate) fn utc(secs: u64) -> String {
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
let (y, mo, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
@@ -242,6 +289,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]
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! What happens when the interface panics. Commands already run under their own guard
|
||||
//! (`cadcraft_engine::guard`); this one covers the rest of the frame, so a panic in a panel keeps
|
||||
//! the user's work: every drawing with unsaved changes is autosaved at once and the app says so.
|
||||
//! A panic that keeps coming back leaves a plain window that only closes, with the recovery copies
|
||||
//! kept for the next start. A panic hook writes a report for each, which stays on this machine.
|
||||
//! None of this helps on the web, where a panic aborts.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{CadApp, now_ms};
|
||||
|
||||
/// Where crash reports go, under the settings folder.
|
||||
pub const REPORT_DIR: &str = "crash-reports";
|
||||
/// This many panics, each within [`QUIET_MS`] of the last, and the interface is given up on.
|
||||
const MAX_STRIKES: u32 = 3;
|
||||
/// A stretch this long without a panic means the app has recovered.
|
||||
const QUIET_MS: f64 = 5_000.0;
|
||||
|
||||
/// Panics caught around the frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UiFault {
|
||||
/// The last panic's message, until the user dismisses the notice.
|
||||
pub message: Option<String>,
|
||||
/// Drawings autosaved because of panics so far.
|
||||
pub autosaved: usize,
|
||||
/// Panics in the current run of them.
|
||||
pub strikes: u32,
|
||||
/// When the last one happened.
|
||||
pub last_ms: f64,
|
||||
/// The interface keeps panicking: it no longer runs, and recovery copies are kept on exit.
|
||||
pub broken: bool,
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
/// [`CadApp::logic`] under the panic guard.
|
||||
pub fn guarded_logic(&mut self, ctx: &egui::Context) {
|
||||
self.guarded(ctx, |app| app.logic(ctx));
|
||||
}
|
||||
|
||||
/// [`CadApp::ui`] under the panic guard, then the notice about any panic. Once the interface
|
||||
/// has been given up on, only the window that closes the app is drawn.
|
||||
pub fn guarded_ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.guarded(&ctx, |app| app.ui(ui));
|
||||
self.show_fault(&ctx);
|
||||
}
|
||||
|
||||
/// The notice about a caught panic, or the window that closes the app.
|
||||
pub fn show_fault(&mut self, ctx: &egui::Context) {
|
||||
if self.fault.broken {
|
||||
broken_window(self, ctx);
|
||||
} else {
|
||||
notice(self, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `frame`; a panic in it autosaves unsaved drawings instead of ending the app.
|
||||
pub fn guarded(&mut self, ctx: &egui::Context, frame: impl FnOnce(&mut CadApp)) {
|
||||
if self.fault.broken {
|
||||
return;
|
||||
}
|
||||
if let Err(p) = catch_unwind(AssertUnwindSafe(|| frame(self))) {
|
||||
let message = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned());
|
||||
self.on_panic(ctx, message.unwrap_or_else(|| "panic".into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_panic(&mut self, ctx: &egui::Context, message: String) {
|
||||
let now = now_ms();
|
||||
let fault = &mut self.fault;
|
||||
fault.strikes = if now - fault.last_ms < QUIET_MS { fault.strikes + 1 } else { 1 };
|
||||
fault.last_ms = now;
|
||||
fault.broken = fault.strikes >= MAX_STRIKES;
|
||||
log::error!("the interface panicked: {message}");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(saver) = &mut self.autosave {
|
||||
let session = &self.session;
|
||||
// The save is the point of being here: a panic inside it must not end the app either.
|
||||
self.fault.autosaved += catch_unwind(AssertUnwindSafe(|| saver.emergency_save(session))).unwrap_or(0);
|
||||
}
|
||||
self.fault.message = Some(message);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice after a panic. It doesn't block the drawing: the user needs to be able to save.
|
||||
fn notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(message) = app.fault.message.clone() else { return };
|
||||
let t = Tokens::get();
|
||||
let mut dismiss = false;
|
||||
egui::Window::new("Internal error")
|
||||
.id(egui::Id::unique("cc_fault_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("Part of CADCraft failed, and the app kept running.");
|
||||
ui.label(saved_text(app.fault.autosaved));
|
||||
ui.label(RichText::new("Save your work and restart CADCraft.").strong());
|
||||
ui.add_space(4.0);
|
||||
details(ui, t, &message);
|
||||
// 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 {
|
||||
app.fault.message = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// All that's left once the interface keeps panicking.
|
||||
fn broken_window(app: &CadApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
egui::Window::new("CADCraft has to close")
|
||||
.id(egui::Id::unique("cc_fault_broken"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.label("Part of CADCraft keeps failing, so it can't continue.");
|
||||
ui.label(saved_text(app.fault.autosaved));
|
||||
ui.label(RichText::new("Drawing Recovery offers them at the next start.").strong());
|
||||
ui.add_space(4.0);
|
||||
if let Some(message) = &app.fault.message {
|
||||
details(ui, t, message);
|
||||
}
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Close CADCraft").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn saved_text(autosaved: usize) -> String {
|
||||
match autosaved {
|
||||
0 => "No drawing needed a new recovery copy.".to_string(),
|
||||
1 => "A recovery copy of the drawing with unsaved changes was autosaved.".to_string(),
|
||||
k => format!("Recovery copies of {k} drawings with unsaved changes were autosaved."),
|
||||
}
|
||||
}
|
||||
|
||||
fn details(ui: &mut egui::Ui, t: Tokens, message: &str) {
|
||||
ui.collapsing("Details", |ui| {
|
||||
ui.label(RichText::new(message).color(t.text_dim).monospace());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = report::last() {
|
||||
ui.label(RichText::new(format!("Report: {}", path.display())).color(t.text_dim).monospace());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use report::install_report_hook;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod report {
|
||||
use std::fmt::Write as _;
|
||||
use std::panic::PanicHookInfo;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Reports one run may write: a panic every frame must not fill the disk.
|
||||
const MAX_PER_RUN: u32 = 5;
|
||||
/// Reports kept in the folder.
|
||||
const KEEP: usize = 20;
|
||||
|
||||
static WRITTEN: AtomicU32 = AtomicU32::new(0);
|
||||
static LAST: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
/// The report of the latest panic, once one was written.
|
||||
pub(super) fn last() -> Option<PathBuf> {
|
||||
LAST.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
/// Write a report into `dir` for every panic, on any thread, before the hook already in
|
||||
/// place runs. Panics that a guard then catches get one too: they are still bugs. A panic that
|
||||
/// can't be reported goes on as it would have: a report never makes things worse.
|
||||
pub fn install_report_hook(dir: PathBuf) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if WRITTEN.fetch_add(1, Ordering::Relaxed) < MAX_PER_RUN
|
||||
&& let Ok(path) = write_report(&dir, info)
|
||||
&& let Ok(mut last) = LAST.lock()
|
||||
{
|
||||
*last = Some(path);
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, info: &PanicHookInfo<'_>) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let mut text = String::new();
|
||||
let _ = writeln!(text, "CADCraft {} on {} {}", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
let _ = writeln!(text, "Time: {} UTC", crate::autosave::utc(now.as_secs()));
|
||||
let _ = writeln!(text, "Thread: {}", std::thread::current().name().unwrap_or("unnamed"));
|
||||
if let Some(l) = info.location() {
|
||||
let _ = writeln!(text, "Location: {}:{}:{}", l.file(), l.line(), l.column());
|
||||
}
|
||||
let _ = writeln!(text, "Message: {}", info.payload_as_str().unwrap_or("(not text)"));
|
||||
let _ = writeln!(text, "\n{}", std::backtrace::Backtrace::force_capture());
|
||||
let path = dir.join(format!("crash-{:012x}-{}.txt", now.as_millis(), std::process::id()));
|
||||
cadcraft_engine::atomic_write::write_atomic(&path, text.as_bytes())?;
|
||||
prune(dir);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Keep the newest [`KEEP`] reports. Names start with the time, so they sort by age.
|
||||
fn prune(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut reports: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.take(10_000)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("crash-") && n.ends_with(".txt")))
|
||||
.collect();
|
||||
reports.sort();
|
||||
let extra = reports.len().saturating_sub(KEEP);
|
||||
for old in reports.drain(..extra) {
|
||||
let _ = std::fs::remove_file(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
pub mod fault;
|
||||
pub mod hatchdlg;
|
||||
mod mtext_editor;
|
||||
mod panel_state;
|
||||
@@ -305,6 +306,10 @@ 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>,
|
||||
/// Panics caught around the frame ([`fault`]).
|
||||
pub fault: fault::UiFault,
|
||||
/// 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 +369,8 @@ impl CadApp {
|
||||
recovery: autosave::Recovery::default(),
|
||||
gpu_faults: std::sync::Arc::default(),
|
||||
gpu_notice: None,
|
||||
fault: fault::UiFault::default(),
|
||||
autosave_notice: None,
|
||||
alias_dlg: None,
|
||||
hatch_dlg: None,
|
||||
bedit: bedit::State::default(),
|
||||
@@ -903,6 +910,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 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());
|
||||
|
||||
// Another drawing's failure is news, even after the first was dismissed.
|
||||
app.autosave_notice.as_mut().unwrap().reported.clear();
|
||||
app.autosave_tick(&ctx);
|
||||
assert!(app.autosave_notice.as_ref().is_some_and(|n| !n.dismissed));
|
||||
assert_eq!(said(&app), 2);
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! A panic in the interface keeps the user's work: unsaved drawings are autosaved at once, the
|
||||
//! app says so and writes a report, and a panic that keeps coming back leaves only a way to close
|
||||
//! with the recovery copies kept. The panic hook 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 panic_in_a_nested_ui(app: &mut CadApp, ctx: &egui::Context, text: &'static str) -> String {
|
||||
let mut painted = String::new();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.guarded(&ctx, |_| {
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label("before");
|
||||
std::panic::panic_any(text);
|
||||
});
|
||||
});
|
||||
});
|
||||
app.show_fault(ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted));
|
||||
painted
|
||||
}
|
||||
|
||||
fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String {
|
||||
let mut painted = String::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.show_fault(ui.ctx()));
|
||||
out.textures_delta.clear(); // no renderer here
|
||||
painted.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted));
|
||||
}
|
||||
painted
|
||||
}
|
||||
|
||||
fn collect(shape: &egui::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => {
|
||||
text.push_str(t.galley.text());
|
||||
text.push('\n');
|
||||
}
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_copies(dir: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "sv$")).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_saves_the_work_and_reports_and_repeated_ones_close_safely() {
|
||||
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-ui-faults-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (auto, reports) = (root.join("autosave"), root.join("crash-reports"));
|
||||
cadcraft_ui_egui::fault::install_report_hook(reports.clone());
|
||||
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.enable_autosave(&auto);
|
||||
app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// One panic: the drawing has a whole recovery copy by the time the frame goes on.
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "a bug in a panel");
|
||||
assert_eq!(app.fault.message.as_deref(), Some("a bug in a panel"));
|
||||
assert_eq!((app.fault.autosaved, app.fault.broken), (1, false));
|
||||
assert_eq!(recovery_copies(&auto), 1);
|
||||
let text = painted_text(&mut app, &ctx);
|
||||
assert!(text.contains("kept running") && text.contains("recovery copy"), "{text}");
|
||||
|
||||
let report = std::fs::read_dir(&reports).unwrap().flatten().next().expect("a report").path();
|
||||
let report = std::fs::read_to_string(report).unwrap();
|
||||
assert!(report.contains("Message: a bug in a panel") && report.contains("ui_faults.rs"), "{report}");
|
||||
assert!(report.starts_with("CADCraft "), "{report}");
|
||||
|
||||
// The app still works after it.
|
||||
app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap();
|
||||
|
||||
// Panics that keep coming: the interface stops running and only offers to close.
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "again");
|
||||
panic_in_a_nested_ui(&mut app, &ctx, "and again");
|
||||
assert!(app.fault.broken);
|
||||
let mut ran = false;
|
||||
app.guarded(&ctx, |_| ran = true);
|
||||
assert!(!ran, "a broken interface isn't run again");
|
||||
assert!(painted_text(&mut app, &ctx).contains("Drawing Recovery offers them"));
|
||||
|
||||
// Closing now keeps the copies for the next start.
|
||||
app.shutdown_autosave();
|
||||
assert_eq!(recovery_copies(&auto), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
Reference in New Issue
Block a user