feat(compare): COMPARE overlays what differs from another drawing

COMPARE picks a drawing (or takes its path) and matches its objects with
this drawing's by what they draw: type, layer and tessellated geometry,
so an unchanged copy matches whatever its handle. Objects only here are
outlined in green, objects only in the other drawing in red, over the
current drawing, and Next / Previous zoom from one difference to the
next with a count in the prompt. A moved or edited object shows as one
of each. eXit (or Esc) clears the overlay.

Refs #970

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 21:31:46 +03:00
1 parent f0ac89629b
commit 58b2f22c09
30 files changed
+372

No files matched your search

+5
View File
@@ -885,6 +885,11 @@ impl OpenCADStudio {
CmdResult::ZoomToWindow { p1, p2 } => {
self.handle_zoom_to_window(p1, p2);
}
CmdResult::Show { p1, p2 } => {
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_to_window(p1.as_vec3(), p2.as_vec3());
}
CmdResult::CommitAndZoom { entity, p1, p2 } => {
self.handle_commit_entities_and_exit(vec![entity], false, false);
self.handle_zoom_to_window(p1, p2);
+36
View File
@@ -3335,6 +3335,42 @@ impl OpenCADStudio {
// Read-only scan: flags entities on undefined layers and block
// references to undefined block definitions. Reports only; it does
// not auto-repair (so it can never make the drawing worse).
// COMPARE — overlay what differs from another drawing. Bare picks
// the drawing; with a path it compares at once.
"COMPARE" => {
return Some(Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Compare with drawing").as_ref())
.add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"])
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
},
|path| match path {
Some(path) => Message::Command(format!("COMPARE {}", path.display())),
None => Message::Noop,
},
));
}
cmd if cmd.starts_with("COMPARE ") => {
let path = std::path::PathBuf::from(cmd.trim_start_matches("COMPARE").trim());
match crate::io::load_file(&path) {
Ok(other) => {
let comparison =
crate::scene::compare::compare(&self.tabs[i].scene, other);
// The overlay shows at once, before the cursor moves.
self.tabs[i].scene.set_preview_wires(comparison.overlay.clone());
let command =
crate::modules::manage::compare::CompareCommand::new(comparison);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
Err(error) => self
.command_line
.push_error(crate::tf!("COMPARE: {error}").as_ref()),
}
}
"AUDIT" => {
use std::collections::BTreeSet;
let mut undefined_layers: BTreeSet<String> = BTreeSet::new();
+5
View File
@@ -1948,6 +1948,11 @@ pub enum CmdResult {
p1: DVec3,
p2: DVec3,
},
/// Zoom to the given corner points; the command stays active.
Show {
p1: DVec3,
p2: DVec3,
},
/// Commit `entity`, then zoom to the given corner points; end command.
CommitAndZoom {
entity: EntityType,
+3
View File
@@ -4227,6 +4227,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"UCS requires origin, X-axis point and XY-plane point." => Some(("common", "ucs-requires-origin-x-axis-point-and-xy-plane-point")),
"DWGUNITS current unit: {current}" => Some(("common", "dwgunits-current-unit")),
"LAYTRANS: {why}." => Some(("common", "laytrans")),
"Compare with drawing" => Some(("common", "compare-with-drawing")),
"COMPARE: {error}" => Some(("common", "compare-error")),
"COMPARE {added} only here (green), {removed} only in the other drawing (red){position} [Next/Previous/eXit]:" => Some(("common", "compare-prompt")),
"LAYTRANS: no layer names in common — use LAYTRANS with no file to map them." => Some(("common", "laytrans-no-layer-names-in-common-use-laytrans-with-no-file-to-map-them")),
"DWGUNITS: not a unit this drawing can use." => Some(("common", "dwgunits-not-a-unit-this-drawing-can-use")),
"DWGUNITS: already in {label}." => Some(("common", "dwgunits-already-in")),
+104
View File
@@ -0,0 +1,104 @@
// COMPARE — show what differs from another drawing and step through it.
//
// COMPARE <added> only here (green), <removed> only in the other drawing
// (red) [Next/Previous/eXit]:
use crate::command::{CadCommand, CmdOption, CmdResult};
use crate::scene::compare::Comparison;
use crate::scene::WireModel;
use glam::DVec3;
pub struct CompareCommand {
comparison: Comparison,
/// The difference shown last; `None` before the first step.
current: Option<usize>,
}
impl CompareCommand {
pub fn new(comparison: Comparison) -> Self {
Self {
comparison,
current: None,
}
}
fn show(&mut self, forward: bool) -> CmdResult {
let count = self.comparison.spots.len();
if count == 0 {
return CmdResult::NeedPoint;
}
let next = match (self.current, forward) {
(None, true) => 0,
(None, false) => count - 1,
(Some(index), true) => (index + 1) % count,
(Some(index), false) => (index + count - 1) % count,
};
self.current = Some(next);
let (min, max) = self.comparison.spots[next];
// A margin around the difference so it is seen in its context.
let margin = ((max - min).length() * 0.5).max(1.0);
CmdResult::Show {
p1: min - DVec3::splat(margin),
p2: max + DVec3::splat(margin),
}
}
}
impl CadCommand for CompareCommand {
fn name(&self) -> &'static str {
"COMPARE"
}
fn prompt(&self) -> String {
let (added, removed) = (self.comparison.added, self.comparison.removed);
let position = match self.current {
Some(index) => format!(" {}/{}", index + 1, self.comparison.spots.len()),
None => String::new(),
};
crate::tf!(
"COMPARE {added} only here (green), {removed} only in the other drawing (red){position} [Next/Previous/eXit]:"
)
.into_owned()
}
fn options(&self) -> Vec<CmdOption> {
vec![
CmdOption::new("Next", "N"),
CmdOption::new("Previous", "P"),
CmdOption::new("eXit", "X"),
]
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() {
"" | "N" | "NEXT" => Some(self.show(true)),
"P" | "PREVIOUS" => Some(self.show(false)),
"X" | "EXIT" => Some(CmdResult::Cancel),
_ => None,
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.show(true)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
// ponytail: the overlay is cloned per frame; cache it in the scene if
// comparisons of very large drawings stutter.
self.comparison.overlay.clone()
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["COMPARE"] });
+1
View File
@@ -2,6 +2,7 @@
mod about;
mod audit;
pub mod compare;
mod cui_export;
mod cui_import;
mod find_nonpurgeable;
+151
View File
@@ -0,0 +1,151 @@
//! COMPARE: what differs between this drawing and another one. Objects are
//! matched by what they draw — type, layer and their tessellated geometry —
//! so a copy that kept its geometry matches whatever its handle, and an
//! object that moved or changed shows as removed in one place and added in
//! the other.
use codec::{CadDocument, Handle};
use glam::DVec3;
use rustc_hash::FxHashMap;
use crate::scene::{Scene, WireModel};
/// The result of a comparison, ready to draw over the current drawing.
pub struct Comparison {
/// Objects only this drawing has.
pub added: usize,
/// Objects only the other drawing has.
pub removed: usize,
/// Their outlines: added in green, removed in red.
pub overlay: Vec<WireModel>,
/// Each difference's extent, in reading order, to step through.
pub spots: Vec<(DVec3, DVec3)>,
}
const ADDED: [f32; 4] = [0.25, 0.85, 0.35, 1.0];
const REMOVED: [f32; 4] = [0.95, 0.3, 0.25, 1.0];
fn wires_by_entity(scene: &Scene) -> FxHashMap<Handle, Vec<WireModel>> {
let mut map: FxHashMap<Handle, Vec<WireModel>> = FxHashMap::default();
for wire in scene.entity_wires().iter() {
if let Some(handle) = Scene::handle_from_wire_name(&wire.name) {
map.entry(handle).or_default().push(wire.clone());
}
}
map
}
fn point(wire: &WireModel, index: usize) -> DVec3 {
let high = wire.points[index];
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
DVec3::new(
f64::from(high[0]) + f64::from(low[0]),
f64::from(high[1]) + f64::from(low[1]),
f64::from(high[2]) + f64::from(low[2]),
)
}
/// What an object draws, as a comparable key: its type, its layer and its
/// outline points rounded to 1e-4 drawing units.
fn signature(document: &CadDocument, handle: Handle, wires: &[WireModel]) -> String {
let mut key = match document.get_entity(handle) {
Some(entity) => format!(
"{}|{}",
crate::entities::names::ui_name(entity),
entity.common().layer
),
None => String::new(),
};
for wire in wires {
key.push('#');
for index in 0..wire.points.len() {
let p = point(wire, index);
if !p.is_finite() {
key.push('/');
continue;
}
let q = (p * 1e4).round();
key.push_str(&format!("{},{},{};", q.x, q.y, q.z));
}
}
key
}
fn extent(wires: &[WireModel]) -> Option<(DVec3, DVec3)> {
let mut bounds: Option<(DVec3, DVec3)> = None;
for wire in wires {
for index in 0..wire.points.len() {
let p = point(wire, index);
if !p.is_finite() {
continue;
}
bounds = Some(match bounds {
None => (p, p),
Some((min, max)) => (min.min(p), max.max(p)),
});
}
}
bounds
}
/// Compare `current` with the drawing `other`, in the space `current`
/// shows.
pub fn compare(current: &Scene, other: CadDocument) -> Comparison {
let mut other_scene = Scene::new();
other_scene.document = other;
other_scene.bump_geometry();
let here = wires_by_entity(current);
let there = wires_by_entity(&other_scene);
let mut unmatched: FxHashMap<String, Vec<Handle>> = FxHashMap::default();
for (handle, wires) in &there {
unmatched
.entry(signature(&other_scene.document, *handle, wires))
.or_default()
.push(*handle);
}
let mut overlay = Vec::new();
let mut spots = Vec::new();
let mut added = 0;
let mut mark = |wires: &[WireModel], color: [f32; 4]| {
if let Some(bounds) = extent(wires) {
spots.push(bounds);
}
overlay.extend(wires.iter().cloned().map(|mut wire| {
wire.color = color;
wire.selected = false;
wire.line_weight_px = wire.line_weight_px.max(2.0);
wire
}));
};
for (handle, wires) in &here {
let key = signature(&current.document, *handle, wires);
match unmatched.get_mut(&key).and_then(Vec::pop) {
Some(_) => {}
None => {
added += 1;
mark(wires, ADDED);
}
}
}
let mut removed = 0;
for handle in unmatched.into_values().flatten() {
if let Some(wires) = there.get(&handle) {
removed += 1;
mark(wires, REMOVED);
}
}
// Reading order: top to bottom, then left to right.
spots.sort_by(|a, b| {
b.1.y
.partial_cmp(&a.1.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.x.partial_cmp(&b.0.x).unwrap_or(std::cmp::Ordering::Equal))
});
Comparison {
added,
removed,
overlay,
spots,
}
}
+1
View File
@@ -7,6 +7,7 @@
// cache — block-definition and property caches
pub mod annotative;
pub mod assoc_array;
pub mod compare;
pub mod block_service;
pub mod cache;
pub mod convert;