mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
AUDIT and RECOVER: find and fix database errors, open damaged drawings repaired (part of #406) (#411)
AUDIT checks for missing layers, linetypes, styles and block definitions,
current-style variables naming missing entries, invalid geometry (NaN or
infinite numbers, zero-length lines, zero-radius circles and arcs),
block definitions that contain themselves, duplicate handles, and
references to missing objects (dimension associations, groups,
constraints, viewport layer lists). It asks "Fix any errors detected?
[Yes/No] <N>:"; the JSON form `audit {fix?}` returns a structured
report. RECOVER opens a file through the read hook and applies AUDIT's
fixes. A question prompt now shows its answers without "or".
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
315751e3ec
commit
f537377355
9 files changed
+1112
-2
No files matched your search
@@ -0,0 +1,643 @@
|
||||
//! AUDIT: find and repair errors in a drawing's database.
|
||||
//!
|
||||
//! [`check`] reports what is wrong; [`repair`] fixes it. Repairs never guess at content: a missing
|
||||
//! layer or block definition is recreated with default properties (an empty block), a reference to
|
||||
//! a missing style or linetype is reassigned to `Standard` or `ByLayer`, references to objects that
|
||||
//! don't exist are dropped, objects whose geometry can't be drawn are removed, block definitions
|
||||
//! that contain themselves lose the reference that closes the loop, and objects sharing a handle
|
||||
//! get new ones.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Handle, Layer, Linetype, MLeaderStyle, TableStyle, TextStyle};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One error found in the drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Issue {
|
||||
/// What is wrong, as a stable identifier (`missingLayer`, `invalidGeometry`, `blockCycle`…).
|
||||
pub kind: &'static str,
|
||||
/// Where: `header`, `tables`, `Model`, `layout <name>`, `block <name>`, `groups` or `constraints`.
|
||||
pub location: String,
|
||||
/// The object concerned, as a hex handle.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
pub message: String,
|
||||
/// What the repair does (or did).
|
||||
pub fix: String,
|
||||
}
|
||||
|
||||
/// The result of an audit.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Report {
|
||||
pub issues: Vec<Issue>,
|
||||
/// Whether the issues were fixed.
|
||||
pub fixed: bool,
|
||||
}
|
||||
|
||||
/// The most issue lines a report message lists; the rest are counted.
|
||||
const MAX_LINES: usize = 100;
|
||||
|
||||
impl Report {
|
||||
pub fn errors(&self) -> usize {
|
||||
self.issues.len()
|
||||
}
|
||||
|
||||
/// The command-line text: one line per issue (up to [`MAX_LINES`]), then the totals.
|
||||
pub fn message(&self) -> String {
|
||||
let mut lines: Vec<String> = self
|
||||
.issues
|
||||
.iter()
|
||||
.take(MAX_LINES)
|
||||
.map(|i| {
|
||||
let what = match &i.handle {
|
||||
Some(h) => format!("{}: object {h}: {}", i.location, i.message),
|
||||
None => format!("{}: {}", i.location, i.message),
|
||||
};
|
||||
if self.fixed { format!("{what} ({})", i.fix) } else { what }
|
||||
})
|
||||
.collect();
|
||||
if self.issues.len() > MAX_LINES {
|
||||
lines.push(format!("… and {} more", self.issues.len() - MAX_LINES));
|
||||
}
|
||||
let n = self.errors();
|
||||
let fixed = if self.fixed { n } else { 0 };
|
||||
lines.push(format!("Audit: {n} error{} found, {fixed} fixed.", if n == 1 { "" } else { "s" }));
|
||||
if n > 0 && !self.fixed {
|
||||
lines.push("Run AUDIT and answer Yes to fix them.".into());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// The programmatic result: `{errors, fixed, issues, message}`.
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"errors": self.errors(),
|
||||
"fixed": if self.fixed { self.errors() } else { 0 },
|
||||
"issues": self.issues,
|
||||
"message": self.message(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit `d` without changing it.
|
||||
pub fn check(d: &Drawing) -> Report {
|
||||
let mut copy = d.clone();
|
||||
Report { issues: repair(&mut copy), fixed: false }
|
||||
}
|
||||
|
||||
/// Audit `d` and fix what is wrong in place. A drawing without errors is left untouched.
|
||||
pub fn fix(d: &mut Drawing) -> Report {
|
||||
Report { issues: repair(d), fixed: true }
|
||||
}
|
||||
|
||||
/// Where a set of objects lives.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Loc {
|
||||
Model,
|
||||
Layout(usize),
|
||||
Block(String),
|
||||
}
|
||||
|
||||
impl Loc {
|
||||
fn label(&self, d: &Drawing) -> String {
|
||||
match self {
|
||||
Loc::Model => "Model".into(),
|
||||
Loc::Layout(i) => format!("layout {}", d.layouts.get(*i).map(|l| l.name.as_str()).unwrap_or("?")),
|
||||
Loc::Block(n) => format!("block {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn locations(d: &Drawing) -> Vec<Loc> {
|
||||
std::iter::once(Loc::Model).chain((0..d.layouts.len()).map(Loc::Layout)).chain(d.blocks.keys().cloned().map(Loc::Block)).collect()
|
||||
}
|
||||
|
||||
fn store<'a>(d: &'a Drawing, l: &Loc) -> Option<&'a EntityStore> {
|
||||
match l {
|
||||
Loc::Model => Some(&d.model),
|
||||
Loc::Layout(i) => d.layouts.get(*i).map(|l| &l.entities),
|
||||
Loc::Block(n) => d.blocks.get(n).map(|b| &b.entities),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_mut<'a>(d: &'a mut Drawing, l: &Loc) -> Option<&'a mut EntityStore> {
|
||||
match l {
|
||||
Loc::Model => Some(&mut d.model),
|
||||
Loc::Layout(i) => d.layouts.get_mut(*i).map(|l| &mut l.entities),
|
||||
Loc::Block(n) => d.blocks.get_mut(n).map(|b| &mut Arc::make_mut(b).entities),
|
||||
}
|
||||
}
|
||||
|
||||
/// A change to one object, applied after its store was read.
|
||||
enum Action {
|
||||
Remove(Handle),
|
||||
Replace(Box<Entity>),
|
||||
/// Give the object (by its old handle) a new handle.
|
||||
Rehandle(Handle),
|
||||
}
|
||||
|
||||
fn apply(d: &mut Drawing, loc: &Loc, actions: Vec<Action>) {
|
||||
if actions.is_empty() {
|
||||
return;
|
||||
}
|
||||
// New handles first: the store is borrowed below.
|
||||
let fresh: Vec<Handle> = actions.iter().filter(|a| matches!(a, Action::Rehandle(_))).map(|_| d.new_handle()).collect();
|
||||
let mut fresh = fresh.into_iter();
|
||||
let Some(st) = store_mut(d, loc) else { return };
|
||||
for a in actions {
|
||||
match a {
|
||||
Action::Remove(h) => {
|
||||
st.remove(h);
|
||||
}
|
||||
Action::Replace(e) => {
|
||||
st.replace(*e);
|
||||
}
|
||||
Action::Rehandle(h) => {
|
||||
if let (Some(e), Some(new)) = (st.remove(h), fresh.next()) {
|
||||
let mut e = (*e).clone();
|
||||
e.handle = new;
|
||||
st.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(kind: &'static str, location: impl Into<String>, handle: Option<Handle>, message: String, fix: impl Into<String>) -> Issue {
|
||||
Issue { kind, location: location.into(), handle: handle.map(Handle::hex), message, fix: fix.into() }
|
||||
}
|
||||
|
||||
/// Find every error in `d` and fix it; returns what was found. Nothing is changed when nothing is
|
||||
/// wrong.
|
||||
pub fn repair(d: &mut Drawing) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
tables(d, &mut out);
|
||||
header(d, &mut out);
|
||||
handseed(d, &mut out);
|
||||
objects(d, &mut out);
|
||||
missing_blocks(d, &mut out);
|
||||
references(d, &mut out);
|
||||
block_cycles(d, &mut out);
|
||||
let all = all_handles(d);
|
||||
groups(d, &all, &mut out);
|
||||
constraints(d, &all, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn has_name<T>(items: &[T], name: &str, of: impl Fn(&T) -> &str) -> bool {
|
||||
items.iter().any(|t| of(t).eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The entries every drawing has, and references between table entries.
|
||||
fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let missing = |what: &str, name: &str, out: &mut Vec<Issue>| {
|
||||
out.push(issue("missingTableEntry", "tables", None, format!("the {what} \"{name}\" is missing"), "recreated with default properties"));
|
||||
};
|
||||
if d.layer("0").is_none() {
|
||||
missing("layer", "0", out);
|
||||
d.layers.insert(0, Layer::default());
|
||||
}
|
||||
for name in ["ByBlock", "ByLayer"] {
|
||||
if d.linetype(name).is_none() {
|
||||
missing("linetype", name, out);
|
||||
d.linetypes.push(Linetype { name: name.into(), description: String::new(), pattern: Vec::new() });
|
||||
}
|
||||
}
|
||||
if d.linetype("Continuous").is_none() {
|
||||
missing("linetype", "Continuous", out);
|
||||
d.linetypes.push(Linetype::continuous());
|
||||
}
|
||||
if d.text_style("Standard").is_none() {
|
||||
missing("text style", "Standard", out);
|
||||
d.text_styles.insert(0, TextStyle::default());
|
||||
}
|
||||
if d.dim_style("Standard").is_none() {
|
||||
missing("dimension style", "Standard", out);
|
||||
d.dim_styles.insert(0, DimStyle::default());
|
||||
}
|
||||
if !has_name(&d.mleader_styles, "Standard", |s| &s.name) {
|
||||
missing("multileader style", "Standard", out);
|
||||
d.mleader_styles.insert(0, MLeaderStyle::default());
|
||||
}
|
||||
if !has_name(&d.table_styles, "Standard", |s| &s.name) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
None,
|
||||
format!("layer \"{}\" uses linetype \"{}\", which doesn't exist", l.name, l.linetype),
|
||||
"set to Continuous",
|
||||
));
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
"missingTextStyle",
|
||||
"tables",
|
||||
None,
|
||||
format!("dimension style \"{}\" uses text style \"{}\", which doesn't exist", s.name, s.text_style),
|
||||
"set to Standard",
|
||||
));
|
||||
s.text_style = "Standard".into();
|
||||
}
|
||||
}
|
||||
for s in &mut d.mleader_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
"missingTextStyle",
|
||||
"tables",
|
||||
None,
|
||||
format!("multileader style \"{}\" uses text style \"{}\", which doesn't exist", s.name, s.text_style),
|
||||
"set to Standard",
|
||||
));
|
||||
s.text_style = "Standard".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current layer, linetype and style variables name entries that exist.
|
||||
fn header(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let vars: [(&str, &str, &str); 6] = [
|
||||
("CLAYER", "layer", "0"),
|
||||
("CELTYPE", "linetype", "ByLayer"),
|
||||
("TEXTSTYLE", "text style", "Standard"),
|
||||
("DIMSTYLE", "dimension style", "Standard"),
|
||||
("CMLEADERSTYLE", "multileader style", "Standard"),
|
||||
("CTABLESTYLE", "table style", "Standard"),
|
||||
];
|
||||
for (var, what, default) in vars {
|
||||
let Some(name) = d.header.get(var).map(|v| v.as_str().map(str::to_string)) else { continue };
|
||||
let exists = name.as_deref().is_some_and(|n| match var {
|
||||
"CLAYER" => d.layer(n).is_some(),
|
||||
"CELTYPE" => d.linetype(n).is_some(),
|
||||
"TEXTSTYLE" => d.text_style(n).is_some(),
|
||||
"DIMSTYLE" => d.dim_style(n).is_some(),
|
||||
"CMLEADERSTYLE" => has_name(&d.mleader_styles, n, |s| &s.name),
|
||||
_ => has_name(&d.table_styles, n, |s| &s.name),
|
||||
});
|
||||
if !exists {
|
||||
let shown = name.map(|n| format!("\"{n}\"")).unwrap_or_else(|| "a value that is not a name".into());
|
||||
out.push(issue(
|
||||
"missingCurrent",
|
||||
"header",
|
||||
None,
|
||||
format!("the current {what} ({var}) is {shown}, which doesn't exist"),
|
||||
format!("set to {default}"),
|
||||
));
|
||||
d.header.set_str(var, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn each_entity(d: &Drawing) -> impl Iterator<Item = &Arc<Entity>> {
|
||||
std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)).flat_map(|s| s.iter())
|
||||
}
|
||||
|
||||
fn all_handles(d: &Drawing) -> HashSet<Handle> {
|
||||
each_entity(d).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The next handle is above every handle in use, so new objects never take an existing one.
|
||||
fn handseed(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let Some(max) = each_entity(d).map(|e| e.handle).max() else { return };
|
||||
if max.0 >= d.handseed {
|
||||
out.push(issue(
|
||||
"handseed",
|
||||
"header",
|
||||
None,
|
||||
format!("the next handle ({:X}) is not above the largest one in use ({})", d.handseed, max.hex()),
|
||||
"moved past it",
|
||||
));
|
||||
d.bump_handseed(max);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `e`'s geometry is invalid, if it is.
|
||||
fn invalid_geometry(e: &Entity) -> Option<&'static str> {
|
||||
if !crate::finite::is_finite(e) {
|
||||
return Some("has infinite or undefined numbers");
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) if l.a == l.b => Some("has zero length"),
|
||||
EntityKind::Circle(c) if c.radius <= 0.0 => Some("has no radius"),
|
||||
EntityKind::Arc(a) if a.radius <= 0.0 => Some("has no radius"),
|
||||
EntityKind::Ellipse(el) if el.major.len() <= 0.0 || el.ratio <= 0.0 => Some("has no size"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects with invalid geometry are removed; handle 0 and handles used twice get new ones.
|
||||
fn objects(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let mut seen: HashSet<Handle> = HashSet::new();
|
||||
for loc in locations(d) {
|
||||
let Some(st) = store(d, &loc) else { continue };
|
||||
let label = loc.label(d);
|
||||
let mut actions = Vec::new();
|
||||
for e in st.iter() {
|
||||
if let Some(why) = invalid_geometry(e) {
|
||||
out.push(issue("invalidGeometry", &label, Some(e.handle), format!("{} {why}", e.kind.type_name()), "removed"));
|
||||
actions.push(Action::Remove(e.handle));
|
||||
} else if e.handle.0 == 0 || !seen.insert(e.handle) {
|
||||
let why = if e.handle.0 == 0 { "has no handle" } else { "has the same handle as another object" };
|
||||
out.push(issue("duplicateHandle", &label, Some(e.handle), format!("{} {why}", e.kind.type_name()), "given a new handle"));
|
||||
actions.push(Action::Rehandle(e.handle));
|
||||
}
|
||||
}
|
||||
apply(d, &loc, actions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Block references whose block definition is missing get an empty definition, keeping the
|
||||
/// references (and their attributes) until the block is redefined.
|
||||
fn missing_blocks(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let mut missing: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for e in each_entity(d) {
|
||||
if let EntityKind::Insert(i) = &e.kind
|
||||
&& d.block(&i.block).is_none()
|
||||
{
|
||||
*missing.entry(i.block.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
for (name, n) in missing {
|
||||
out.push(issue(
|
||||
"missingBlock",
|
||||
"tables",
|
||||
None,
|
||||
format!("{n} block reference{} to \"{name}\", which has no definition", if n == 1 { "" } else { "s" }),
|
||||
"created an empty block definition",
|
||||
));
|
||||
d.blocks.insert(name.clone(), Arc::new(Block::new(&name)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers objects are on that don't exist are created with default properties.
|
||||
fn missing_layers(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
// By folded name: a file may have very many layers.
|
||||
let mut known: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
for e in each_entity(d) {
|
||||
let n = &e.common.layer;
|
||||
if !n.trim().is_empty() && known.insert(n.to_ascii_lowercase()) {
|
||||
missing.push(n.clone());
|
||||
}
|
||||
}
|
||||
for n in missing {
|
||||
out.push(issue(
|
||||
"missingLayer",
|
||||
"tables",
|
||||
None,
|
||||
format!("layer \"{n}\" is used by objects but doesn't exist"),
|
||||
"created with default properties",
|
||||
));
|
||||
d.layers.push(Layer::new(&n));
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing layers, linetypes, styles, dimension blocks and associated objects referenced by objects.
|
||||
fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let all = all_handles(d);
|
||||
let ok_text = |d: &Drawing, n: &str| d.text_style(n).is_some();
|
||||
let ok_dim = |d: &Drawing, n: &str| d.dim_style(n).is_some();
|
||||
// Missing names, counted: (what, name) → (objects, first location).
|
||||
let mut counts: BTreeMap<(&'static str, String), (usize, String)> = BTreeMap::new();
|
||||
missing_layers(d, out);
|
||||
for loc in locations(d) {
|
||||
let Some(st) = store(d, &loc) else { continue };
|
||||
let label = loc.label(d);
|
||||
let mut actions = Vec::new();
|
||||
for e in st.iter() {
|
||||
let mut fixed = (**e).clone();
|
||||
let mut note = |what: &'static str, name: &str| {
|
||||
let c = counts.entry((what, name.to_string())).or_insert((0, label.clone()));
|
||||
c.0 += 1;
|
||||
};
|
||||
if fixed.common.layer.trim().is_empty() {
|
||||
note("layer", "");
|
||||
fixed.common.layer = "0".into();
|
||||
}
|
||||
let lt = &fixed.common.linetype;
|
||||
if !lt.eq_ignore_ascii_case("ByLayer") && !lt.eq_ignore_ascii_case("ByBlock") && d.linetype(lt).is_none() {
|
||||
note("linetype", lt);
|
||||
fixed.common.linetype = "ByLayer".into();
|
||||
}
|
||||
let mut text_style = |s: &mut String| {
|
||||
if !ok_text(d, s) {
|
||||
note("text style", s);
|
||||
*s = "Standard".into();
|
||||
}
|
||||
};
|
||||
match &mut fixed.kind {
|
||||
EntityKind::Text(t) => text_style(&mut t.style),
|
||||
EntityKind::MText(t) => text_style(&mut t.style),
|
||||
EntityKind::AttDef(a) => text_style(&mut a.text.style),
|
||||
EntityKind::Insert(i) => i.attribs.iter_mut().for_each(|a| text_style(&mut a.text.style)),
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
text_style(&mut t.style);
|
||||
}
|
||||
if !has_name(&d.mleader_styles, &m.style, |s| &s.name) {
|
||||
note("multileader style", &m.style);
|
||||
m.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Leader(l) => {
|
||||
if !ok_dim(d, &l.style) {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dim) => {
|
||||
if !ok_dim(d, &dim.style) {
|
||||
note("dimension style", &dim.style);
|
||||
dim.style = "Standard".into();
|
||||
}
|
||||
if let Some(b) = &dim.block
|
||||
&& d.block(b).is_none()
|
||||
{
|
||||
out.push(issue(
|
||||
"missingBlock",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
format!("dimension's block \"{b}\" doesn't exist"),
|
||||
"the dimension draws itself again",
|
||||
));
|
||||
dim.block = None;
|
||||
}
|
||||
let before = dim.assoc.len();
|
||||
dim.assoc.retain(|a| all.contains(&a.handle));
|
||||
if dim.assoc.len() < before {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
"dimension is associated with an object that doesn't exist".into(),
|
||||
"association removed",
|
||||
));
|
||||
}
|
||||
}
|
||||
EntityKind::Viewport(vp) => {
|
||||
let before = vp.frozen_layers.len() + vp.layer_colors.len();
|
||||
vp.frozen_layers.retain(|n| d.layer(n).is_some());
|
||||
vp.layer_colors.retain(|(n, _)| d.layer(n).is_some());
|
||||
if vp.frozen_layers.len() + vp.layer_colors.len() < before {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
"viewport lists layer settings for layers that don't exist".into(),
|
||||
"those entries removed",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if fixed != **e {
|
||||
actions.push(Action::Replace(Box::new(fixed)));
|
||||
}
|
||||
}
|
||||
apply(d, &loc, actions);
|
||||
}
|
||||
for ((what, name), (n, first)) in counts {
|
||||
let (fix, shown) = match what {
|
||||
"layer" => ("put on layer 0".to_string(), "no name".to_string()),
|
||||
"linetype" => ("set to ByLayer".into(), format!("\"{name}\"")),
|
||||
_ => ("set to Standard".into(), format!("\"{name}\"")),
|
||||
};
|
||||
out.push(issue(
|
||||
"missingReference",
|
||||
first,
|
||||
None,
|
||||
format!("{n} object{} {what} {shown}, which doesn't exist", if n == 1 { " uses" } else { "s use" }),
|
||||
fix,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Block definitions that contain themselves (directly or through other blocks) lose the
|
||||
/// reference that closes the loop.
|
||||
fn block_cycles(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let names: Vec<String> = d.blocks.keys().cloned().collect();
|
||||
// Names resolve as `Drawing::block` does: the exact name, else one differing only in case.
|
||||
let exact: HashMap<&str, usize> = names.iter().enumerate().map(|(i, n)| (n.as_str(), i)).collect();
|
||||
let folded: HashMap<String, usize> = names.iter().enumerate().rev().map(|(i, n)| (n.to_ascii_lowercase(), i)).collect();
|
||||
let lookup = |n: &str| exact.get(n).or_else(|| folded.get(&n.to_ascii_lowercase())).copied();
|
||||
let edges: Vec<Vec<(Handle, usize)>> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
d.blocks
|
||||
.get(n)
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => lookup(&i.block).map(|t| (e.handle, t)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
// Iterative depth-first search: 0 unvisited, 1 on the current path, 2 done.
|
||||
let mut state = vec![0u8; names.len()];
|
||||
let mut cuts: Vec<(usize, Handle, usize)> = Vec::new();
|
||||
for root in 0..names.len() {
|
||||
if state.get(root) != Some(&0) {
|
||||
continue;
|
||||
}
|
||||
let mut stack: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
if let Some(s) = state.get_mut(root) {
|
||||
*s = 1;
|
||||
}
|
||||
while let Some((node, next)) = stack.last_mut() {
|
||||
let node = *node;
|
||||
match edges.get(node).and_then(|es| es.get(*next)) {
|
||||
Some(&(h, to)) => {
|
||||
*next += 1;
|
||||
match state.get(to) {
|
||||
Some(1) => cuts.push((node, h, to)),
|
||||
Some(0) => {
|
||||
if let Some(s) = state.get_mut(to) {
|
||||
*s = 1;
|
||||
}
|
||||
stack.push((to, 0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(s) = state.get_mut(node) {
|
||||
*s = 2;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (from, h, to) in cuts {
|
||||
let (Some(from), Some(to)) = (names.get(from), names.get(to)) else { continue };
|
||||
let msg = if from == to {
|
||||
"block contains a reference to itself".to_string()
|
||||
} else {
|
||||
format!("reference to block \"{to}\" makes the block contain itself")
|
||||
};
|
||||
out.push(issue("blockCycle", format!("block {from}"), Some(h), msg, "reference removed"));
|
||||
apply(d, &Loc::Block(from.clone()), vec![Action::Remove(h)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups list only objects that exist.
|
||||
fn groups(d: &mut Drawing, all: &HashSet<Handle>, out: &mut Vec<Issue>) {
|
||||
for g in &mut d.groups {
|
||||
let before = g.members.len();
|
||||
g.members.retain(|h| all.contains(h));
|
||||
let gone = before - g.members.len();
|
||||
if gone > 0 {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
"groups",
|
||||
None,
|
||||
format!("group \"{}\" lists {gone} object{} exist", g.name, if gone == 1 { " that doesn't" } else { "s that don't" }),
|
||||
"removed from the group",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constraints refer only to objects that exist.
|
||||
fn constraints(d: &mut Drawing, all: &HashSet<Handle>, out: &mut Vec<Issue>) {
|
||||
let mut kept = Vec::with_capacity(d.constraints.len());
|
||||
for c in std::mem::take(&mut d.constraints) {
|
||||
match c.refs.iter().find(|r| !all.contains(&r.handle)) {
|
||||
Some(r) => out.push(issue(
|
||||
"danglingReference",
|
||||
"constraints",
|
||||
Some(r.handle),
|
||||
format!("constraint {} refers to an object that doesn't exist", c.id),
|
||||
"constraint removed",
|
||||
)),
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
d.constraints = kept;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! AUDIT and RECOVER (File ▸ Drawing Utilities): check a drawing for errors and fix them, and open
|
||||
//! a damaged file with the fixes applied. The checks themselves live in [`crate::audit`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("audit", "Audit", run_audit)
|
||||
.menu(&["File", "Drawing Utilities", "Audit"])
|
||||
.params("{fix?: bool} → {errors, fixed, issues: [{kind, location, handle?, message, fix}], message}")
|
||||
.interactive(|_| Ok(Box::new(Audit))),
|
||||
CommandSpec::new("recover", "Recover...", run_recover)
|
||||
.menu(&["File", "Drawing Utilities", "Recover..."])
|
||||
.params("{path} | {data: base64, name} → {index, errors, fixed, issues, message}")
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(Recover))),
|
||||
]
|
||||
}
|
||||
|
||||
/// Check the current drawing; with `fix`, repair it (one undo step).
|
||||
fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = bool_or(p, "fix", false);
|
||||
let mut copy = (*s.doc()?).clone();
|
||||
let issues = crate::audit::repair(&mut copy);
|
||||
// A drawing without errors stays the same object: no undo step, no unsaved changes.
|
||||
if fix && !issues.is_empty() {
|
||||
s.state_mut()?.doc = Arc::new(copy);
|
||||
}
|
||||
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
|
||||
}
|
||||
|
||||
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
|
||||
/// drawing keeps its file name; when anything was fixed it opens with unsaved changes.
|
||||
fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, path) = match (str_param(p, "path"), str_param(p, "data")) {
|
||||
(Some(path), _) => (read_path("recover", path)?, path.to_string(), Some(path.to_string())),
|
||||
(None, Some(data)) => {
|
||||
let bytes = file::base64_decode(data).ok_or_else(|| bad("recover", "invalid base64"))?;
|
||||
(bytes, str_param(p, "name").unwrap_or("Drawing.dxf").to_string(), None)
|
||||
}
|
||||
(None, None) => return Err(bad("recover", "`path` or `data` (base64) is required")),
|
||||
};
|
||||
let hooks = file::io().ok_or_else(|| bad("recover", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(&bytes, &name).map_err(|e| bad("recover", format!("{name} can't be read: {e}")))?;
|
||||
let title = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone());
|
||||
let i = s.open_drawing(d, &title, path);
|
||||
let st = s.state_mut()?;
|
||||
let report = crate::audit::fix(Arc::make_mut(&mut st.doc));
|
||||
if report.issues.is_empty() {
|
||||
// Nothing to fix: the drawing opens unchanged, like OPEN.
|
||||
st.doc = st.saved.clone();
|
||||
}
|
||||
let mut r = report.to_json();
|
||||
r["index"] = json!(i);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(cmd: &str, _path: &str) -> Result<Vec<u8>> {
|
||||
Err(bad(cmd, "paths are not available on the web; pass `data`"))
|
||||
}
|
||||
|
||||
const YES_NO: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
fn echo_result(s: &mut Session, r: &Value) {
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AUDIT at the command line: "Fix any errors detected? [Yes/No] <N>:".
|
||||
struct Audit;
|
||||
|
||||
impl Interactive for Audit {
|
||||
fn name(&self) -> &'static str {
|
||||
"AUDIT"
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Fix any errors detected?", YES_NO).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let fix = match i {
|
||||
Input::Cancel => return Ok(Step::Cancel),
|
||||
Input::Enter => false,
|
||||
Input::Keyword(k) | Input::Text(k) => match self.prompt(s).match_keyword(&k).as_deref() {
|
||||
Some("Yes") => true,
|
||||
Some(_) => false,
|
||||
None => return Err(bad("audit", "answer Yes or No")),
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let r = run_audit(s, &json!({ "fix": fix }))?;
|
||||
echo_result(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// RECOVER at the command line (no file dialog): the file name is typed.
|
||||
struct Recover;
|
||||
|
||||
impl Interactive for Recover {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECOVER"
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of drawing file to recover", Accept::TEXT)
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Cancel | Input::Enter => Ok(Step::Cancel),
|
||||
Input::Text(t) | Input::Keyword(t) => {
|
||||
let path = t.trim().trim_matches('"');
|
||||
if path.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let r = run_recover(s, &json!({ "path": path }))?;
|
||||
echo_result(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
mod annotate;
|
||||
mod array;
|
||||
mod audit;
|
||||
mod blocks;
|
||||
pub mod clipboard;
|
||||
pub mod constraints;
|
||||
@@ -152,6 +153,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(audit::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(qselect::specs());
|
||||
v.extend(view::specs());
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod assoc;
|
||||
pub mod audit;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grips;
|
||||
|
||||
@@ -78,7 +78,15 @@ impl Prompt {
|
||||
pub fn display(&self) -> String {
|
||||
let mut s = self.message.clone();
|
||||
if !self.keywords.is_empty() {
|
||||
s.push_str(if self.message.is_empty() { "[" } else { " or [" });
|
||||
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
|
||||
// offers options besides what it asks for ("Specify next point or [Undo]").
|
||||
s.push_str(if self.message.is_empty() {
|
||||
"["
|
||||
} else if self.message.ends_with('?') {
|
||||
" ["
|
||||
} else {
|
||||
" or ["
|
||||
});
|
||||
s.push_str(&self.keywords.join("/"));
|
||||
s.push(']');
|
||||
}
|
||||
@@ -226,6 +234,8 @@ mod tests {
|
||||
fn keywords() {
|
||||
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
|
||||
assert_eq!(p.display(), "Specify next point or [Undo/Close]:");
|
||||
let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
|
||||
assert_eq!(q.display(), "Erase polyline? [Yes/No] <No>:");
|
||||
assert_eq!(p.match_keyword("u").as_deref(), Some("Undo"));
|
||||
assert_eq!(p.match_keyword("CL").as_deref(), Some("Close"));
|
||||
assert_eq!(p.match_keyword("x"), None);
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! AUDIT finds and fixes database errors; RECOVER opens a damaged drawing with the fixes applied
|
||||
//! (#406, docs/gaps.md F4).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_io};
|
||||
use cadcraft_engine::doc::{AssocSnap, Block, Circle, Common, DimAssoc, Drawing, Entity, EntityKind, Group, HVal, Handle, Insert, Line, Space, Text};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
|
||||
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> EntityKind {
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn on(layer: &str) -> Common {
|
||||
Common { layer: layer.into(), ..Common::default() }
|
||||
}
|
||||
|
||||
/// Push an object without the checks `Drawing::add` makes (it would create the layer).
|
||||
fn raw(d: &mut Drawing, space: &Space, common: Common, kind: EntityKind) -> Handle {
|
||||
let h = d.new_handle();
|
||||
d.space_mut(space).unwrap().push(Entity { handle: h, common, kind });
|
||||
h
|
||||
}
|
||||
|
||||
/// A drawing with one of each error AUDIT knows about, plus a good line and an associative
|
||||
/// dimension. Returns the handles of the good line and the dimension.
|
||||
fn corrupt(d: &mut Drawing) -> (Handle, Handle) {
|
||||
let good = raw(d, &Space::Model, on("0"), line((0.0, 0.0), (10.0, 0.0)));
|
||||
raw(d, &Space::Model, on("Ghost"), line((0.0, 5.0), (10.0, 5.0)));
|
||||
raw(
|
||||
d,
|
||||
&Space::Model,
|
||||
Common { linetype: "NoSuchDash".into(), ..on("0") },
|
||||
EntityKind::Circle(Circle { center: Vec3::new(3.0, 3.0, 0.0), radius: 1.0 }),
|
||||
);
|
||||
raw(d, &Space::Model, on("0"), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.0 }));
|
||||
raw(d, &Space::Model, on("0"), line((f64::NAN, 0.0), (1.0, 1.0)));
|
||||
raw(d, &Space::Model, on("0"), line((2.0, 2.0), (2.0, 2.0)));
|
||||
raw(
|
||||
d,
|
||||
&Space::Model,
|
||||
on("0"),
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "A".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Vanished".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
);
|
||||
raw(d, &Space::Model, on("0"), insert("Nowhere"));
|
||||
// A and B contain each other; C contains itself.
|
||||
for (name, inner) in [("A", "B"), ("B", "A"), ("C", "C")] {
|
||||
let mut b = Block::new(name);
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: on("0"), kind: insert(inner) });
|
||||
d.blocks.insert(name.into(), Arc::new(b));
|
||||
}
|
||||
raw(d, &Space::Model, on("0"), insert("A"));
|
||||
// The same handle in model space and in a layout.
|
||||
let dup = d.model.iter().nth(2).unwrap().handle;
|
||||
d.space_mut(&Space::Paper("Layout1".into())).unwrap().push(Entity { handle: dup, common: on("0"), kind: line((0.0, 0.0), (1.0, 1.0)) });
|
||||
d.groups.push(Group { name: "G".into(), description: String::new(), selectable: true, members: vec![good, Handle(0xDEAD)] });
|
||||
d.header.set("CLAYER", HVal::Str("Gone".into()));
|
||||
d.header.set_str("DIMSTYLE", "Lost");
|
||||
let dim = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).unwrap();
|
||||
d.modify_entity(dim, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.assoc.push(DimAssoc { point: "p13".into(), handle: Handle(0xBEEF), snap: AssocSnap::Start });
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
(good, dim)
|
||||
}
|
||||
|
||||
/// A session whose drawing has a dimension, then every error from [`corrupt`].
|
||||
fn corrupt_session() -> (Session, Handle, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [4, 0], "at": [2, 2]})).unwrap();
|
||||
let mut d = (*s.doc().unwrap()).clone();
|
||||
let (good, dim) = corrupt(&mut d);
|
||||
let st = s.state_mut().unwrap();
|
||||
st.doc = Arc::new(d);
|
||||
st.saved = st.doc.clone();
|
||||
(s, good, dim)
|
||||
}
|
||||
|
||||
fn kinds(r: &Value) -> Vec<String> {
|
||||
r["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_reports_without_changing_and_fixes_when_asked() {
|
||||
let (mut s, good, dim) = corrupt_session();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
|
||||
let r = s.execute("audit", &json!({})).unwrap();
|
||||
let k = kinds(&r);
|
||||
for want in [
|
||||
"missingLayer",
|
||||
"missingReference",
|
||||
"invalidGeometry",
|
||||
"duplicateHandle",
|
||||
"missingBlock",
|
||||
"blockCycle",
|
||||
"danglingReference",
|
||||
"missingCurrent",
|
||||
] {
|
||||
assert!(k.iter().any(|x| x == want), "{want} not reported: {r:#}");
|
||||
}
|
||||
// NaN line, zero radius circle, zero length line.
|
||||
assert_eq!(k.iter().filter(|x| *x == "invalidGeometry").count(), 3, "{r:#}");
|
||||
assert_eq!(r["fixed"], 0);
|
||||
assert!(r["errors"].as_u64().unwrap() >= 12);
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc), "a report without fix changes nothing");
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!(r["fixed"], r["errors"]);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layer("Ghost").is_some(), "missing layer recreated");
|
||||
assert!(d.block("Nowhere").is_some_and(|b| b.entities.is_empty()), "missing block recreated empty");
|
||||
assert_eq!(d.header.str("CLAYER", ""), "0");
|
||||
assert_eq!(d.header.str("DIMSTYLE", ""), "Standard");
|
||||
assert_eq!(d.groups[0].members, vec![good]);
|
||||
let EntityKind::Dimension(dm) = &d.entity(dim).unwrap().kind else { panic!() };
|
||||
assert!(dm.assoc.iter().all(|a| a.handle != Handle(0xBEEF)));
|
||||
for e in d.model.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => assert_eq!(t.style, "Standard"),
|
||||
EntityKind::Circle(c) => {
|
||||
assert!(c.radius > 0.0);
|
||||
assert_eq!(e.common.linetype, "ByLayer");
|
||||
}
|
||||
EntityKind::Line(l) => assert!(l.a != l.b && l.a.x.is_finite()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Every handle is unique and below the next one.
|
||||
let mut all: Vec<Handle> = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| e.handle).collect();
|
||||
all.extend(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)));
|
||||
let n = all.len();
|
||||
all.sort();
|
||||
all.dedup();
|
||||
assert_eq!(all.len(), n, "duplicate handles remain");
|
||||
assert!(all.iter().all(|h| h.0 < d.handseed));
|
||||
|
||||
// Clean now; one undo step brings the errors back.
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
assert!(s.state().unwrap().is_dirty());
|
||||
s.undo().unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_drawing_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [5, 5]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [1, 1], "radius": 2})).unwrap();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!((r["errors"].as_u64(), r["fixed"].as_u64()), (Some(0), Some(0)));
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for nothing");
|
||||
|
||||
// What the sample drawings and the drawing commands make is not an error.
|
||||
for d in [cadcraft_engine::sample::bracket(), cadcraft_engine::sample::floor_plan()] {
|
||||
let r = cadcraft_engine::audit::check(&d);
|
||||
assert!(r.issues.is_empty(), "{}", r.message());
|
||||
}
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [4, 0], "at": [2, 2]})).unwrap();
|
||||
s.execute(
|
||||
"dimradius",
|
||||
&json!({"handle": s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Circle(_))).unwrap().handle.hex(), "at": [4, 4]}),
|
||||
)
|
||||
.ok();
|
||||
s.script("mtext 0,0 5,-5\nNote\n\nhatch p solid 0,0 \n").ok();
|
||||
s.execute("block", &json!({"name": "Bolt", "base": [0, 0], "handles": s.doc().unwrap().model.handles()[..2]})).unwrap();
|
||||
s.execute("insert", &json!({"name": "Bolt", "at": [20, 20]})).unwrap();
|
||||
let r = s.execute("audit", &json!({})).unwrap();
|
||||
assert_eq!(r["errors"], 0, "{r:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_at_the_command_line_asks_before_fixing() {
|
||||
let (mut s, _, _) = corrupt_session();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
s.cmdline("audit").unwrap();
|
||||
assert_eq!(s.prompt_text(), "AUDIT Fix any errors detected? [Yes/No] <N>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc), "Enter takes No");
|
||||
assert!(s.log.iter().any(|l| l.contains("Run AUDIT and answer Yes")), "{:?}", s.log);
|
||||
|
||||
s.cmdline("audit").unwrap();
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(!Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
let summary = s.log.iter().rev().find(|l| l.starts_with("Audit:")).unwrap();
|
||||
assert!(summary.contains("fixed") && !summary.contains(" 0 fixed"), "{summary}");
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_standard_entries_and_layer_zero_are_recreated() {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.layers.retain(|l| l.name != "0");
|
||||
d.text_styles.clear();
|
||||
d.dim_styles.clear();
|
||||
d.linetypes.retain(|l| !l.name.eq_ignore_ascii_case("Continuous"));
|
||||
d.layers.push(cadcraft_engine::doc::Layer { linetype: "Dashed9".into(), ..cadcraft_engine::doc::Layer::new("L1") });
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert!(r["errors"].as_u64().unwrap() >= 5, "{r:#}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layer("0").is_some() && d.text_style("Standard").is_some() && d.dim_style("Standard").is_some());
|
||||
assert!(d.linetype("Continuous").is_some());
|
||||
assert_eq!(d.layer("L1").unwrap().linetype, "Continuous");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_block_cycle_is_broken_without_deep_recursion() {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let n = 20_000;
|
||||
for i in 0..n {
|
||||
let mut b = Block::new(&format!("B{i}"));
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: on("0"), kind: insert(&format!("B{}", (i + 1) % n)) });
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!(kinds(&r).iter().filter(|k| *k == "blockCycle").count(), 1, "one reference closes the loop");
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
}
|
||||
|
||||
fn read_corrupt(_: &[u8], name: &str) -> Result<Drawing, String> {
|
||||
if name.ends_with(".bad") {
|
||||
return Err("not a drawing".into());
|
||||
}
|
||||
let mut d = Drawing::new_imperial();
|
||||
raw(&mut d, &Space::Model, on("Ghost"), line((0.0, 0.0), (1.0, 0.0)));
|
||||
raw(&mut d, &Space::Model, on("0"), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: -1.0 }));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_opens_the_file_with_the_fixes_applied() {
|
||||
set_io(IoHooks { read: read_corrupt, write: |_, _| Ok(Vec::new()), plot: None });
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("recover", &json!({"data": base64_encode(b"0\nEOF\n"), "name": "plan.dxf"})).unwrap();
|
||||
assert_eq!(r["errors"], 2, "{r:#}");
|
||||
assert_eq!(r["fixed"], 2);
|
||||
assert_eq!(r["index"], 1);
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!(st.title, "plan.dxf");
|
||||
assert!(st.is_dirty(), "the fixes are unsaved changes");
|
||||
assert!(st.doc.layer("Ghost").is_some());
|
||||
assert_eq!(st.doc.model.len(), 1);
|
||||
|
||||
// By path, typed at the command line.
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-recover-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("site.dxf");
|
||||
std::fs::write(&path, b"0\nEOF\n").unwrap();
|
||||
s.cmdline("recover").unwrap();
|
||||
assert_eq!(s.prompt_text(), "RECOVER Enter name of drawing file to recover:");
|
||||
s.cmdline(&path.to_string_lossy()).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!((st.title.as_str(), st.path.as_deref()), ("site.dxf", Some(path.to_string_lossy().as_ref())));
|
||||
assert!(s.log.iter().any(|l| l == "Audit: 2 errors found, 2 fixed."), "{:?}", s.log);
|
||||
|
||||
// A file the reader rejects is an error; nothing opens.
|
||||
let docs = s.docs.len();
|
||||
assert!(s.execute("recover", &json!({"data": "", "name": "x.bad"})).is_err());
|
||||
assert!(s.execute("recover", &json!({})).is_err());
|
||||
assert_eq!(s.docs.len(), docs);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
Attribute Атрибут
|
||||
Attribute Definition Визначення атрибута
|
||||
Attribute Display Відображення атрибутів
|
||||
Audit Перевірка креслення
|
||||
Auto (system language) Авто (мова системи)
|
||||
Auto Constrain Автоматичні обмеження
|
||||
AutoConstrain Автоматичні обмеження
|
||||
@@ -194,6 +195,7 @@
|
||||
Drawing (DWG) Креслення (DWG)
|
||||
Drawing (DXF) Креслення (DXF)
|
||||
Drawing Limits Межі креслення
|
||||
Drawing Utilities Утиліти креслення
|
||||
Drawings (DWG, DXF) Креслення (DWG, DXF)
|
||||
Dynamic Dimensions Динамічні розміри
|
||||
Dynamic Input Динамічне введення
|
||||
@@ -564,6 +566,7 @@
|
||||
Realtime У реальному часі
|
||||
Reassociate Dimensions Відновити асоціативність розмірів
|
||||
Recent Нещодавні
|
||||
Recover... Відновити...
|
||||
Rectangle Прямокутник
|
||||
Rectangular Прямокутний
|
||||
Rectangular Array Прямокутний масив
|
||||
|
||||
|
@@ -84,6 +84,15 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the
|
||||
// file name on the command line.
|
||||
"recover" if no_path && app.services.pick_open.is_some() => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
let r = app.run("recover", json!({ "path": p }));
|
||||
app.opened(r);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.saveas" | "saveas" if no_path => {
|
||||
let title = app.session.state().map(|s| s.title.clone()).ok();
|
||||
let name = app.ui.save_format.suggested_name(title.as_deref());
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ do them first.
|
||||
| F1 | **[alpha blocker]** Prompt options print "not available yet": SPLINE Method, Knots, Object, Tangency, toLerance; MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns; MLEADER options (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/draw.rs` (`SplineM`), `modify2.rs:594,783,806` | High: the commands people use most | 15–25 |
|
||||
| F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 |
|
||||
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
|
||||
| F4 | No autosave (SAVETIME), crash recovery files, AUDIT, RECOVER, drawing recovery manager | no SAVETIME anywhere | High: lost work | 10–15 |
|
||||
| F4 | No autosave (SAVETIME), crash recovery files or drawing recovery manager (AUDIT and RECOVER landed with #406; AUDITCTL `.adt` logs and a RECOVER mode in the file readers are not there) | no SAVETIME anywhere | High: lost work | 6–10 |
|
||||
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |
|
||||
| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 |
|
||||
|
||||
Reference in new issue
Block a user