fix(xclip): precise clip + carry clip across cross-drawing paste

Clipped blocks were broken at UTM and lost their clip when pasted into
another drawing.

- Clip geometry: world_clip_polygon_f64 keeps the boundary in absolute f64;
  clip_wires now clips in a frame relative to the boundary's first vertex
  (small coords → exact f32 Sutherland–Hodgman) and re-splits the result
  into the double-single high/low pair the relative-to-eye renderer needs,
  so the clipped block draws correctly and no longer poisons ZOOM Extents.
- ZOOM Extents (fit_all): the outlier reject compared absolute UTM
  coordinates against a centre-RELATIVE span (local_extent_max), rejecting
  the whole drawing → no-op. Made the reject distance-from-median-centre.
- Cross-drawing paste: clipboard now snapshots each copied entity's whole
  xdictionary object graph (ClipboardDeps.ext_objects) and recreates it on
  paste with fresh handles + remapped references, re-pointing the pasted
  entity's xdictionary at the new root. Wired into PASTECLIP and PASTEORIG.
  Fixed handle allocation (allocate_handle, not the non-advancing
  next_handle) so the dictionary chain doesn't collapse onto one handle.
- Updated hit_test / plugin_host tests for the eye-relative + DVec3 signatures.

Known gaps: PASTEBLOCK does not yet carry the clip (clip would need to live
inside the new wrapper block definition); hatch-clip path still f32.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-24 09:33:55 +03:00
1 parent bca8958025
commit 6667042372
7 files changed
+330 -26

No files matched your search

+115 -3
View File
@@ -627,7 +627,10 @@ impl OpenCADStudio {
// else a block reference renders empty. (#135)
self.merge_clipboard_blocks(i);
let count = self.clipboard.len();
let new_handles: Vec<Handle> = self
// Index-aligned with `clipboard` (NULL = add failed) so the
// captured extension-dictionary subtrees can be matched back
// to their pasted entity by index.
let by_index: Vec<Handle> = self
.clipboard
.clone()
.into_iter()
@@ -635,10 +638,12 @@ impl OpenCADStudio {
crate::scene::view::dispatch::apply_transform(&mut entity, &translate);
self.tabs[i].scene.add_entity_clone(entity)
})
.filter(|h| !h.is_null())
.collect();
// Recreate each pasted entity's xdictionary graph (XCLIP
// spatial filters etc.) in this document. (#xclip-paste)
self.merge_clipboard_ext_objects(i, &by_index);
self.tabs[i].scene.deselect_all();
for h in new_handles {
for h in by_index.iter().copied().filter(|h| !h.is_null()) {
self.tabs[i].scene.select_entity(h, false);
}
// Tessellate any pasted ACIS solids — top-level ones and
@@ -1867,6 +1872,54 @@ impl OpenCADStudio {
}
}
/// Recreate the extension-dictionary object graph (XCLIP spatial filters,
/// attached XRecords, …) captured for each copied entity, cloning every
/// object into this document with fresh handles, remapping all internal
/// references, and re-pointing the pasted entity's `xdictionary_handle` at
/// the new root. `by_index` is the paste's new entity handles, aligned with
/// the clipboard order (NULL where the add failed). No-op without captures.
pub(super) fn merge_clipboard_ext_objects(&mut self, i: usize, by_index: &[Handle]) {
use std::collections::HashMap;
if self.clipboard_deps.ext_objects.is_empty() {
return;
}
let captures = self.clipboard_deps.ext_objects.clone();
let doc = &mut self.tabs[i].scene.document;
for cap in captures {
let Some(&new_entity) = by_index.get(cap.entity_index) else {
continue;
};
if new_entity.is_null() {
continue;
}
// old handle → fresh handle for the whole subtree, plus the entity
// itself so an object owned by the entity remaps onto the new copy.
let mut remap: HashMap<Handle, Handle> = HashMap::new();
remap.insert(cap.src_entity_handle, new_entity);
for (old, _) in &cap.objects {
// `allocate_handle` advances the counter; `next_handle()` only
// peeks, so reusing it here would hand every object the same
// handle and they'd overwrite each other in `doc.objects`.
remap.insert(*old, doc.allocate_handle());
}
for (old, obj) in &cap.objects {
let mut obj = obj.clone();
let new_h = remap[old];
remap_object(&mut obj, new_h, &remap);
doc.objects.insert(new_h, obj);
}
// Point the pasted entity at the cloned root dictionary.
if let Some(new_root) = remap.get(&cap.root).copied() {
if let Some(e) = doc.get_entity_mut(new_entity) {
e.common_mut().xdictionary_handle = Some(new_root);
}
}
}
// The wires were tessellated before the filters existed; force a rebuild
// so the clip is applied to the freshly-pasted, now-filtered inserts.
self.tabs[i].scene.bump_geometry();
}
fn restore_pre_cmd_tangent(&mut self) {
if let Some(was_on) = self.pre_cmd_tangent.take() {
if !was_on {
@@ -1881,6 +1934,65 @@ impl OpenCADStudio {
}
}
/// Rewrite a cloned extension-dictionary object onto fresh handles: set its own
/// handle to `new_handle` and remap its owner and any handle references it holds
/// through `remap` (a handle still in the source space stays unchanged, which is
/// correct for cross-references that point outside the captured subtree).
fn remap_object(
obj: &mut acadrust::objects::ObjectType,
new_handle: acadrust::Handle,
remap: &std::collections::HashMap<acadrust::Handle, acadrust::Handle>,
) {
use acadrust::objects::ObjectType;
let map = |h: acadrust::Handle| remap.get(&h).copied().unwrap_or(h);
match obj {
ObjectType::Dictionary(d) => {
d.handle = new_handle;
d.owner = map(d.owner);
for (_, h) in d.entries.iter_mut() {
*h = map(*h);
}
if let Some(x) = d.xdictionary_handle.as_mut() {
*x = map(*x);
}
for r in d.reactors.iter_mut() {
*r = map(*r);
}
}
ObjectType::DictionaryWithDefault(d) => {
d.handle = new_handle;
d.owner = map(d.owner);
for (_, h) in d.entries.iter_mut() {
*h = map(*h);
}
d.default_handle = map(d.default_handle);
}
ObjectType::DictionaryVariable(v) => {
v.handle = new_handle;
v.owner_handle = map(v.owner_handle);
}
ObjectType::SpatialFilter(s) => {
s.handle = new_handle;
s.owner = map(s.owner);
}
ObjectType::XRecord(x) => {
x.handle = new_handle;
x.owner = map(x.owner);
}
ObjectType::Group(g) => {
g.handle = new_handle;
g.owner = map(g.owner);
for h in g.entities.iter_mut() {
*h = map(*h);
}
}
// Other leaf object kinds don't appear in an entity xdictionary; if one
// does, it's inserted with the fresh handle below via the caller's key,
// but its internal owner is left as-is (best effort).
_ => {}
}
}
// ── DIMSPACE helper ───────────────────────────────────────────────────────────
/// Parse `base_val,h1;h2;...;hN,spacing` and adjust parallel dimension positions.
+10 -3
View File
@@ -631,9 +631,16 @@ impl OpenCADStudio {
// else a block reference renders empty in a drawing that
// lacks the definition. (#135 / #158)
self.merge_clipboard_blocks(i);
for entity in self.clipboard.clone() {
self.tabs[i].scene.add_entity_clone(entity);
}
let by_index: Vec<acadrust::Handle> = self
.clipboard
.clone()
.into_iter()
.map(|entity| self.tabs[i].scene.add_entity_clone(entity))
.collect();
// Recreate each pasted entity's xdictionary graph (XCLIP
// spatial filters etc.) so a cross-drawing paste keeps its
// clip instead of showing the whole block. (#xclip-paste)
self.merge_clipboard_ext_objects(i, &by_index);
// Tessellate any pasted ACIS solids (top-level + inside a
// recreated block) so they render instead of staying blank.
self.tabs[i].scene.populate_meshes_from_document();
+77
View File
@@ -665,6 +665,11 @@ pub struct ClipboardDeps {
/// reference doesn't render empty in a drawing that lacks the
/// definition. (#135)
pub blocks: Vec<BlockDef>,
/// Extension-dictionary object subtrees hanging off the copied entities
/// (XCLIP spatial filters, attached XRecords, …). Each entity's whole
/// `xdictionary` graph is snapshotted so a cross-drawing paste recreates it
/// — without this a pasted clipped block loses its clip and renders whole.
pub ext_objects: Vec<ClipExtObjects>,
}
/// A captured block definition: its base point and the entities it owns
@@ -677,6 +682,19 @@ pub struct BlockDef {
pub entities: Vec<acadrust::EntityType>,
}
/// The extension-dictionary object graph captured for one copied entity.
/// `objects` holds every object reachable from the entity's `xdictionary`
/// (dictionaries + their leaf objects), keyed by their source handles; `root`
/// is the xdictionary handle. On paste the whole set is cloned into the target
/// document with fresh handles and the references are remapped.
#[derive(Clone)]
pub struct ClipExtObjects {
pub entity_index: usize,
pub src_entity_handle: acadrust::Handle,
pub root: acadrust::Handle,
pub objects: Vec<(acadrust::Handle, acadrust::objects::ObjectType)>,
}
impl ClipboardDeps {
/// Snapshot the records `entities` reference that exist in `doc`.
pub fn capture(doc: &acadrust::CadDocument, entities: &[acadrust::EntityType]) -> Self {
@@ -728,15 +746,74 @@ impl ClipboardDeps {
_ => {}
}
}
// Extension-dictionary subtree per entity (XCLIP filters etc.).
let mut ext_objects = Vec::new();
for (entity_index, e) in entities.iter().enumerate() {
let c = e.common();
if let Some(root) = c.xdictionary_handle {
if root.is_null() {
continue;
}
let objects = Self::collect_ext_subtree(doc, root);
if !objects.is_empty() {
ext_objects.push(ClipExtObjects {
entity_index,
src_entity_handle: c.handle,
root,
objects,
});
}
}
}
ClipboardDeps {
layers: layers.iter().filter_map(|n| doc.layers.get(n).cloned()).collect(),
linetypes: ltypes.iter().filter_map(|n| doc.line_types.get(n).cloned()).collect(),
text_styles: tstyles.iter().filter_map(|n| doc.text_styles.get(n).cloned()).collect(),
dim_styles: dstyles.iter().filter_map(|n| doc.dim_styles.get(n).cloned()).collect(),
blocks,
ext_objects,
}
}
/// Breadth-first collect of every object reachable from extension-dictionary
/// `root` (dictionary entries, nested xdictionaries, dictionary defaults),
/// returned as `(source_handle, object)` pairs. Cycle-safe.
fn collect_ext_subtree(
doc: &acadrust::CadDocument,
root: acadrust::Handle,
) -> Vec<(acadrust::Handle, acadrust::objects::ObjectType)> {
use acadrust::objects::ObjectType;
use rustc_hash::FxHashSet;
let mut seen: FxHashSet<acadrust::Handle> = FxHashSet::default();
let mut queue = vec![root];
let mut out = Vec::new();
while let Some(h) = queue.pop() {
if h.is_null() || !seen.insert(h) {
continue;
}
let Some(obj) = doc.objects.get(&h) else {
continue;
};
// Enqueue children referenced by this object.
match obj {
ObjectType::Dictionary(d) => {
queue.extend(d.entries.iter().map(|(_, ch)| *ch));
if let Some(x) = d.xdictionary_handle {
queue.push(x);
}
}
ObjectType::DictionaryWithDefault(d) => {
queue.extend(d.entries.iter().map(|(_, ch)| *ch));
queue.push(d.default_handle);
}
_ => {}
}
out.push((h, obj.clone()));
}
out
}
/// Snapshot every block definition the `entities` reference through an
/// INSERT, walking nested INSERTs transitively. Model/paper space and
/// xref blocks are skipped — those aren't portable definitions.
+2 -2
View File
@@ -356,7 +356,7 @@ mod tests {
host.start_interactive(Box::new(PlacePoint { got_first: false }));
}
assert!(app.tabs[0].active_cmd.is_some());
for pt in [glam::Vec3::new(0.0, 0.0, 0.0), glam::Vec3::new(5.0, 5.0, 0.0)] {
for pt in [glam::DVec3::new(0.0, 0.0, 0.0), glam::DVec3::new(5.0, 5.0, 0.0)] {
let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt);
let _ = app.apply_cmd_result(r);
}
@@ -410,7 +410,7 @@ mod tests {
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(target, glam::Vec3::new(3.0, 4.0, 0.0));
.on_entity_pick(target, glam::DVec3::new(3.0, 4.0, 0.0));
let _ = app.apply_cmd_result(r);
// Original point + the mark the command committed.
assert_eq!(app.tabs[0].scene.document.entities().count(), 2);