mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 12:30:16 -06:00
Squash of PR #572 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
17 KiB
Rust
423 lines
17 KiB
Rust
//! COMPARE: compare the drawing with another one (a file read through the io hooks, or an open
|
|
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
|
|
//! then by handle: the same handle with other geometry or properties is a change. What is left
|
|
//! is only in this drawing or only in the other. The comparison stays on the session for the
|
|
//! canvas overlay and next/previous navigation until COMPARECLOSE.
|
|
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::Arc;
|
|
|
|
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
|
use cadcraft_geom::{Bounds2, Vec2};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::*;
|
|
use super::{base64_decode, io};
|
|
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
/// Objects compared per drawing; more are left out (and the result says so).
|
|
pub const MAX_OBJECTS: usize = 200_000;
|
|
/// Objects listed per kind of difference in the JSON result.
|
|
const MAX_LISTED: usize = 1000;
|
|
/// Block definition objects hashed into a reference's signature, per comparison.
|
|
const MAX_BLOCK_WORK: usize = 1_000_000;
|
|
/// The change areas: the extents are cut into at most this many cells per side.
|
|
const GRID: usize = 32;
|
|
|
|
/// A drawing comparison: what differs between the current drawing and another one.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Comparison {
|
|
/// The drawing compared ([`crate::DocState::uid`]).
|
|
pub uid: u64,
|
|
/// The space compared (the tab shown when COMPARE ran).
|
|
pub space: Space,
|
|
/// The other drawing's name.
|
|
pub other_name: String,
|
|
/// The other drawing (its objects are drawn where they differ).
|
|
pub other: Arc<Drawing>,
|
|
/// Only in this drawing (handles here).
|
|
pub added: Vec<Handle>,
|
|
/// Only in the other drawing (handles there).
|
|
pub removed: Vec<Handle>,
|
|
/// The same handle in both with other geometry or properties: (here, there).
|
|
pub changed: Vec<(Handle, Handle)>,
|
|
/// Objects found in both.
|
|
pub common: usize,
|
|
/// More than [`MAX_OBJECTS`] objects: the rest were not compared.
|
|
pub truncated: bool,
|
|
/// Areas holding differences, in reading order (top to bottom, left to right).
|
|
pub regions: Vec<Bounds2>,
|
|
/// The area shown last by next/previous (`None` before the first).
|
|
pub at: Option<usize>,
|
|
}
|
|
|
|
impl Comparison {
|
|
pub fn differences(&self) -> usize {
|
|
self.added.len() + self.removed.len() + self.changed.len()
|
|
}
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("compare", "Compare Drawings...", run_compare)
|
|
.menu(&["Tools", "Compare Drawings..."])
|
|
.params(
|
|
"{path} | {data: base64, name} | {document: index or title} → {other, added: [{handle, type}], removed, \
|
|
changed: [{handle, other, type, what}], counts: {added, removed, changed, common}, regions, truncated, message}",
|
|
)
|
|
.noundo()
|
|
.interactive(|_| Ok(Box::new(CompareM))),
|
|
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
|
|
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
|
|
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
|
|
]
|
|
}
|
|
|
|
/// Read the drawing to compare with from `{path}`, `{data, name}` or `{document}`.
|
|
fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
|
const CMD: &str = "compare";
|
|
if let Some(d) = p.get("document").filter(|v| !v.is_null()) {
|
|
let st = match (d.as_u64(), d.as_str()) {
|
|
(Some(i), _) => usize::try_from(i).ok().and_then(|i| s.docs.get(i)),
|
|
(_, Some(t)) => s.docs.iter().find(|st| st.title.eq_ignore_ascii_case(t.trim())),
|
|
_ => None,
|
|
};
|
|
let st = st.ok_or_else(|| bad(CMD, format!("no open drawing {d}")))?;
|
|
return Ok((st.doc.clone(), st.title.clone()));
|
|
}
|
|
let hooks = io().ok_or_else(|| bad(CMD, "file formats are not available in this build"))?;
|
|
if let Some(path) = str_param(p, "path") {
|
|
let bytes = read_path(path)?;
|
|
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string();
|
|
let d = (hooks.read)(&bytes, path).map_err(|e| bad(CMD, format!("{name}: {e}")))?;
|
|
return Ok((Arc::new(d), name));
|
|
}
|
|
let data = str_param(p, "data").ok_or_else(|| bad(CMD, "`path`, `data` (base64) or `document` is required"))?;
|
|
let bytes = base64_decode(data).ok_or_else(|| bad(CMD, "invalid base64"))?;
|
|
let name = str_param(p, "name").unwrap_or("Drawing.dxf").to_string();
|
|
let d = (hooks.read)(&bytes, &name).map_err(|e| bad(CMD, format!("{name}: {e}")))?;
|
|
Ok((Arc::new(d), name))
|
|
}
|
|
|
|
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
|
}
|
|
|
|
/// Rounds coordinates (1e-6) so that a drawing written and read back still matches itself.
|
|
fn round(v: &mut Value, depth: usize) {
|
|
if depth > 64 {
|
|
return;
|
|
}
|
|
match v {
|
|
Value::Number(n) if n.is_f64() => {
|
|
if let Some(f) = n.as_f64() {
|
|
let r = (f * 1e6).round() / 1e6;
|
|
*v = json!(if r == 0.0 { 0.0 } else { r });
|
|
}
|
|
}
|
|
Value::Array(a) => a.iter_mut().for_each(|x| round(x, depth + 1)),
|
|
Value::Object(o) => o.values_mut().for_each(|x| round(x, depth + 1)),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Signatures of objects: geometry and properties hashed separately; references include their
|
|
/// block definition's contents.
|
|
struct Signer<'a> {
|
|
d: &'a Drawing,
|
|
blocks: HashMap<String, u64>,
|
|
work: usize,
|
|
}
|
|
|
|
impl<'a> Signer<'a> {
|
|
fn new(d: &'a Drawing) -> Self {
|
|
Signer { d, blocks: HashMap::new(), work: 0 }
|
|
}
|
|
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
let mut v = serde_json::to_value(k).unwrap_or(Value::Null);
|
|
// What is derived from the geometry is not compared: a dimension's generated block, its
|
|
// cached measurement, and its text position unless the user placed the text.
|
|
if let (Some(o), EntityKind::Dimension(dm)) = (v.as_object_mut(), k) {
|
|
o.remove("block");
|
|
o.remove("measurement");
|
|
if !dm.user_text_pos {
|
|
o.remove("textMid");
|
|
}
|
|
}
|
|
round(&mut v, 0);
|
|
v.to_string().hash(&mut h);
|
|
if let EntityKind::Insert(i) = k {
|
|
self.block(&i.block, depth).hash(&mut h);
|
|
}
|
|
h.finish()
|
|
}
|
|
fn block(&mut self, name: &str, depth: usize) -> u64 {
|
|
let key = name.to_ascii_lowercase();
|
|
if let Some(v) = self.blocks.get(&key) {
|
|
return *v;
|
|
}
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
key.hash(&mut h);
|
|
if depth < MAX_BLOCK_DEPTH
|
|
&& let Some(b) = self.d.block(name)
|
|
{
|
|
for e in b.entities.iter() {
|
|
if self.work >= MAX_BLOCK_WORK {
|
|
break;
|
|
}
|
|
self.work += 1;
|
|
self.geometry(&e.kind, depth + 1).hash(&mut h);
|
|
properties(e).hash(&mut h);
|
|
}
|
|
}
|
|
let v = h.finish();
|
|
self.blocks.insert(key, v);
|
|
v
|
|
}
|
|
}
|
|
|
|
fn properties(e: &Entity) -> u64 {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
let c = &e.common;
|
|
c.layer.to_ascii_lowercase().hash(&mut h);
|
|
format!("{:?}|{}|{:?}|{}", c.color, c.linetype.to_ascii_lowercase(), c.lineweight, (c.ltscale * 1e6).round()).hash(&mut h);
|
|
h.finish()
|
|
}
|
|
|
|
struct Obj<'a> {
|
|
e: &'a Arc<Entity>,
|
|
geom: u64,
|
|
props: u64,
|
|
}
|
|
|
|
fn objects<'a>(d: &'a Drawing, space: &Space, truncated: &mut bool) -> Vec<Obj<'a>> {
|
|
let mut sg = Signer::new(d);
|
|
let Some(store) = d.space(space) else { return Vec::new() };
|
|
let mut out = Vec::new();
|
|
for e in store.iter() {
|
|
if out.len() >= MAX_OBJECTS {
|
|
*truncated = true;
|
|
break;
|
|
}
|
|
out.push(Obj { e, geom: sg.geometry(&e.kind, 0), props: properties(e) });
|
|
}
|
|
out
|
|
}
|
|
|
|
/// What differs between two drawings' spaces.
|
|
struct Diff {
|
|
added: Vec<Handle>,
|
|
removed: Vec<Handle>,
|
|
/// (here, there, geometry differs, properties differ).
|
|
changed: Vec<(Handle, Handle, bool, bool)>,
|
|
common: usize,
|
|
truncated: bool,
|
|
}
|
|
|
|
/// Compare `cur`'s `space` with the same space of `other`.
|
|
fn diff(cur: &Drawing, other: &Drawing, space: &Space) -> Diff {
|
|
let mut truncated = false;
|
|
let a = objects(cur, space, &mut truncated);
|
|
let b = objects(other, space, &mut truncated);
|
|
let mut b_left: Vec<bool> = vec![true; b.len()];
|
|
let mut a_left: Vec<bool> = vec![true; a.len()];
|
|
let by_handle: HashMap<Handle, usize> = b.iter().enumerate().map(|(i, o)| (o.e.handle, i)).collect();
|
|
let mut common = 0;
|
|
// 1. The same handle, geometry and properties.
|
|
for (i, o) in a.iter().enumerate() {
|
|
if let Some(&j) = by_handle.get(&o.e.handle)
|
|
&& let Some(q) = b.get(j)
|
|
&& q.geom == o.geom
|
|
&& q.props == o.props
|
|
&& let (Some(al), Some(bl)) = (a_left.get_mut(i), b_left.get_mut(j))
|
|
{
|
|
(*al, *bl) = (false, false);
|
|
common += 1;
|
|
}
|
|
}
|
|
// 2. Equal geometry and properties under another handle (a file from another program).
|
|
let mut by_sig: HashMap<(u64, u64), Vec<usize>> = HashMap::new();
|
|
for (j, q) in b.iter().enumerate().rev() {
|
|
if b_left.get(j).copied().unwrap_or(false) {
|
|
by_sig.entry((q.geom, q.props)).or_default().push(j);
|
|
}
|
|
}
|
|
for (i, o) in a.iter().enumerate() {
|
|
if !a_left.get(i).copied().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
if let Some(j) = by_sig.get_mut(&(o.geom, o.props)).and_then(Vec::pop)
|
|
&& let (Some(al), Some(bl)) = (a_left.get_mut(i), b_left.get_mut(j))
|
|
{
|
|
(*al, *bl) = (false, false);
|
|
common += 1;
|
|
}
|
|
}
|
|
// 3. The same handle and type, other geometry or properties: changed.
|
|
let mut changed = Vec::new();
|
|
for (i, o) in a.iter().enumerate() {
|
|
if !a_left.get(i).copied().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
if let Some(&j) = by_handle.get(&o.e.handle)
|
|
&& b_left.get(j).copied().unwrap_or(false)
|
|
&& let Some(q) = b.get(j)
|
|
&& q.e.kind.type_name() == o.e.kind.type_name()
|
|
&& let (Some(al), Some(bl)) = (a_left.get_mut(i), b_left.get_mut(j))
|
|
{
|
|
(*al, *bl) = (false, false);
|
|
changed.push((o.e.handle, q.e.handle, o.geom != q.geom, o.props != q.props));
|
|
}
|
|
}
|
|
let added = a.iter().zip(&a_left).filter(|(_, l)| **l).map(|(o, _)| o.e.handle).collect();
|
|
let removed = b.iter().zip(&b_left).filter(|(_, l)| **l).map(|(o, _)| o.e.handle).collect();
|
|
Diff { added, removed, changed, common, truncated }
|
|
}
|
|
|
|
/// Group the differences into areas: one per occupied cell of a grid over their extents.
|
|
fn regions(boxes: &[Bounds2]) -> Vec<Bounds2> {
|
|
let mut all = Bounds2::EMPTY;
|
|
for b in boxes {
|
|
all = all.union(b);
|
|
}
|
|
if all.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let (w, h) = (all.width().max(1e-9), all.height().max(1e-9));
|
|
let mut cells: HashMap<(usize, usize), Bounds2> = HashMap::new();
|
|
for b in boxes.iter().filter(|b| !b.is_empty()) {
|
|
let c = b.center();
|
|
let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor().max(0.0) as usize).min(GRID - 1);
|
|
let k = (cell(c.x, all.min.x, w), cell(c.y, all.min.y, h));
|
|
let cell = cells.entry(k).or_insert(Bounds2::EMPTY);
|
|
*cell = cell.union(b);
|
|
}
|
|
let mut out: Vec<((usize, usize), Bounds2)> = cells.into_iter().collect();
|
|
// Reading order: top row first, left to right.
|
|
out.sort_by(|a, b| b.0.1.cmp(&a.0.1).then(a.0.0.cmp(&b.0.0)));
|
|
out.into_iter().map(|(_, b)| b).collect()
|
|
}
|
|
|
|
fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
|
|
hs.iter().take(MAX_LISTED).filter_map(|h| d.entity(*h)).map(|e| json!({ "handle": e.handle.hex(), "type": e.kind.type_name() })).collect()
|
|
}
|
|
|
|
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (other, name) = other_drawing(s, p)?;
|
|
let st = s.state()?;
|
|
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
|
|
if Arc::ptr_eq(&cur, &other) {
|
|
return Err(bad("compare", "a drawing can't be compared with itself"));
|
|
}
|
|
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
|
|
let mut boxes = Vec::new();
|
|
let mut bound = |d: &Drawing, h: Handle| {
|
|
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
|
boxes.push(b);
|
|
}
|
|
};
|
|
added.iter().for_each(|h| bound(&cur, *h));
|
|
removed.iter().for_each(|h| bound(&other, *h));
|
|
for (a, b, _, _) in &changed {
|
|
bound(&cur, *a);
|
|
bound(&other, *b);
|
|
}
|
|
let regions = regions(&boxes);
|
|
let ch: Vec<Value> = changed
|
|
.iter()
|
|
.take(MAX_LISTED)
|
|
.filter_map(|(a, b, geom, props)| {
|
|
let x = cur.entity(*a)?;
|
|
let what = match (geom, props) {
|
|
(true, true) => "geometry and properties",
|
|
(true, false) => "geometry",
|
|
_ => "properties",
|
|
};
|
|
Some(json!({ "handle": a.hex(), "other": b.hex(), "type": x.kind.type_name(), "what": what }))
|
|
})
|
|
.collect();
|
|
let message = format!(
|
|
"Compared with {name}: {} only in this drawing, {} only in {name}, {} changed, {common} the same{}.",
|
|
added.len(),
|
|
removed.len(),
|
|
changed.len(),
|
|
if truncated { format!(" (only the first {MAX_OBJECTS} objects of each were compared)") } else { String::new() }
|
|
);
|
|
let out = json!({
|
|
"other": name,
|
|
"added": listed(&cur, &added),
|
|
"removed": listed(&other, &removed),
|
|
"changed": ch,
|
|
"counts": { "added": added.len(), "removed": removed.len(), "changed": changed.len(), "common": common },
|
|
"regions": regions.iter().map(|b| json!([[b.min.x, b.min.y], [b.max.x, b.max.y]])).collect::<Vec<_>>(),
|
|
"truncated": truncated,
|
|
"message": message,
|
|
});
|
|
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
|
|
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
|
|
s.touch();
|
|
Ok(out)
|
|
}
|
|
|
|
/// Zoom to the next (`dir` 1) or previous (-1) area with changes.
|
|
fn step(s: &mut Session, dir: i64) -> Result<Value> {
|
|
let uid = s.state()?.uid;
|
|
let c = s.compare.as_mut().filter(|c| c.uid == uid).ok_or_else(|| bad("compare", "no comparison: run COMPARE first"))?;
|
|
let n = c.regions.len();
|
|
if n == 0 {
|
|
return Ok(json!({ "region": Value::Null, "of": 0, "message": "No differences." }));
|
|
}
|
|
let i = match (c.at, dir > 0) {
|
|
(None, true) => 0,
|
|
(None, false) => n - 1,
|
|
(Some(i), true) => (i + 1) % n,
|
|
(Some(i), false) => (i + n - 1) % n,
|
|
};
|
|
c.at = Some(i);
|
|
let Some(b) = c.regions.get(i).copied() else { return Err(bad("compare", "no such area")) };
|
|
// The area with as much room around it (and a size for a single point).
|
|
let pad = b.width().max(b.height()).max(1e-3);
|
|
let (p1, p2) = (b.min - Vec2::new(pad, pad), b.max + Vec2::new(pad, pad));
|
|
s.execute("zoom", &json!({ "mode": "window", "p1": [p1.x, p1.y], "p2": [p2.x, p2.y] }))?;
|
|
Ok(json!({ "region": i, "of": n, "message": format!("Change area {} of {n}.", i + 1) }))
|
|
}
|
|
|
|
fn run_close(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let had = s.compare.take().is_some();
|
|
s.touch();
|
|
Ok(json!({ "closed": had }))
|
|
}
|
|
|
|
/// COMPARE at the command line: the drawing to compare with, a file path or an open drawing's name.
|
|
struct CompareM;
|
|
|
|
impl Interactive for CompareM {
|
|
fn name(&self) -> &'static str {
|
|
"COMPARE"
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
Prompt::new("Enter the drawing to compare with (an open drawing's name or a file path)", Accept::TEXT)
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
let Input::Text(t) = i else { return Ok(Step::Done) };
|
|
let t = t.trim().trim_matches('"');
|
|
if t.is_empty() {
|
|
return Ok(Step::Done);
|
|
}
|
|
let open = s.docs.iter().enumerate().any(|(i, d)| i != s.active && d.title.eq_ignore_ascii_case(t));
|
|
let p = if open { json!({ "document": t }) } else { json!({ "path": t }) };
|
|
let r = run_compare(s, &p)?;
|
|
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
|
s.echo(m.to_string());
|
|
}
|
|
Ok(Step::Done)
|
|
}
|
|
}
|