diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index e16e8acc6..336e2a3c3 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -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 = 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 = 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 = 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, +) { + 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. diff --git a/src/app/commands.rs b/src/app/commands.rs index a39884b50..1079dbcae 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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 = 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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 32fd79687..2269a1837 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -665,6 +665,11 @@ pub struct ClipboardDeps { /// reference doesn't render empty in a drawing that lacks the /// definition. (#135) pub blocks: Vec, + /// 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, } /// A captured block definition: its base point and the entities it owns @@ -677,6 +682,19 @@ pub struct BlockDef { pub entities: Vec, } +/// 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 = 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. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index b3aba53c6..0cddd7478 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -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); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 44b87c0c6..be582d3f9 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6670,9 +6670,6 @@ impl Scene { if !x.is_finite() || !y.is_finite() { continue; } - if x.abs() > lim || y.abs() > lim { - continue; - } sx += x as f64; sy += y as f64; n += 1; @@ -6689,9 +6686,26 @@ impl Scene { return; } + // Robust drawing centre (median centroid). `lim` is a span RELATIVE to + // this centre, so every reject below is distance-from-centre — geometry + // now reaches fit_all as absolute coordinates (no world_offset), which + // at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the + // entire drawing and make ZOOM Extents a no-op. + let (mcx, mcy) = { + let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); + let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); + if xs.is_empty() { + (0.0_f32, 0.0_f32) + } else { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + (xs[xs.len() / 2], ys[ys.len() / 2]) + } + }; + // IQR-based reject only kicks in with enough samples for the quartiles - // to be meaningful. Below that, the absolute `lim` filter is the only - // gate (legacy behavior). + // to be meaningful. Below that, the centre-relative `lim` filter is the + // only gate (legacy behavior). let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 { let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); @@ -6712,7 +6726,7 @@ impl Scene { let dy = (q3y - q1y).max(1.0) * K; (q1x - dx, q3x + dx, q1y - dy, q3y + dy) } else { - (-lim, lim, -lim, lim) + (mcx - lim, mcx + lim, mcy - lim, mcy + lim) }; let mut min = glam::Vec3::splat(f32::MAX); @@ -6726,7 +6740,7 @@ impl Scene { if !x.is_finite() || !y.is_finite() || !z.is_finite() { continue; } - if x.abs() > lim || y.abs() > lim { + if (x - mcx).abs() > lim || (y - mcy).abs() > lim { continue; } min = min.min(glam::Vec3::new(x, y, z)); @@ -8274,7 +8288,7 @@ fn tessellate_entity( // clip the expanded block geometry to the boundary polygon so // only the portion inside the clip is drawn. if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) { - let poly = pick::xclip::world_clip_polygon(sf, ins, world_offset); + let poly = pick::xclip::world_clip_polygon_f64(sf, ins, world_offset); pick::xclip::clip_wires(&mut wires, &poly); } diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 296651f6b..40c751544 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -759,9 +759,10 @@ mod aabb_reject_tests { let near = wire("5", vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], [-0.02, 0.0, 0.02, 0.0]); let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]); - assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, bounds), Some("5")); - assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, bounds), None); + let eye = glam::DVec3::ZERO; + assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds), Some("5")); + assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds), None); // The far wire must be rejected without hiding the near one. - assert_eq!(click_hit(cursor, &[far, near], vp, bounds), Some("5")); + assert_eq!(click_hit(cursor, &[far, near], vp, eye, bounds), Some("5")); } } diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 98a5bff42..9aaac2034 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -67,6 +67,21 @@ pub fn world_clip_polygon( ins: &Insert, world_offset: [f64; 3], ) -> Vec<[f32; 2]> { + world_clip_polygon_f64(sf, ins, world_offset) + .into_iter() + .map(|[x, y]| [x as f32, y as f32]) + .collect() +} + +/// f64 variant of [`world_clip_polygon`]. The boundary stays in absolute world +/// coordinates so clipping at UTM scale (~5.7e6) is precise — the f32 version +/// quantizes each vertex by ~0.5 m, which warps the clip region and breaks both +/// the clipped render and ZOOM Extents. +pub fn world_clip_polygon_f64( + sf: &SpatialFilter, + ins: &Insert, + world_offset: [f64; 3], +) -> Vec<[f64; 2]> { let xform = ins.get_transform(); let inv_block = &sf.inverse_block_transform; let [ox, oy, _] = world_offset; @@ -82,7 +97,7 @@ pub fn world_clip_polygon( .map(|[x, y]| { let block = inv_block.transform_point(Vector3::new(x, y, 0.0)); let w = xform.apply(block); - [(w.x - ox) as f32, (w.y - oy) as f32] + [w.x - ox, w.y - oy] }) .collect() } @@ -91,25 +106,102 @@ pub fn world_clip_polygon( /// world_offset-subtracted). Polylines are split into NaN-separated inside /// runs; fill triangles are clipped against the polygon; snap / key vertices /// outside the boundary are dropped. Wires left with no geometry are removed. -pub fn clip_wires(wires: &mut Vec, poly: &[[f32; 2]]) { +pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { if poly.len() < 3 { return; } + // Clip in a frame relative to the boundary's first vertex: the wire points + // arrive as absolute coordinates (double-single high+low), which at UTM + // scale are ~5.7e6 and lose ~0.5 m in f32. Subtracting the f64 reference + // makes every coordinate small, so the f32 Sutherland–Hodgman math is exact; + // the reference is added back afterwards and re-split into the high/low pair + // the relative-to-eye renderer expects. + let (rx, ry) = (poly[0][0], poly[0][1]); + let lpoly: Vec<[f32; 2]> = poly + .iter() + .map(|&[x, y]| [(x - rx) as f32, (y - ry) as f32]) + .collect(); + + // Reconstruct an absolute-f64 wire point from its high/low pair, NaN-safe. + let abs = |hi: [f32; 3], lo: [f32; 3]| -> [f64; 3] { + [ + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ] + }; + for w in wires.iter_mut() { if !w.points.is_empty() { - w.points = clip_polyline(&w.points, poly); + // Absolute → local f32 (NaN separators preserved). + let local: Vec<[f32; 3]> = (0..w.points.len()) + .map(|i| { + let hi = w.points[i]; + if !hi[0].is_finite() || !hi[1].is_finite() { + return NAN3; + } + let lo = w.points_low.get(i).copied().unwrap_or([0.0; 3]); + let a = abs(hi, lo); + [(a[0] - rx) as f32, (a[1] - ry) as f32, a[2] as f32] + }) + .collect(); + let clipped = clip_polyline(&local, &lpoly); + // Local → absolute, re-split into double-single high/low. + let mut hi = Vec::with_capacity(clipped.len()); + let mut lo = Vec::with_capacity(clipped.len()); + for p in clipped { + if !p[0].is_finite() || !p[1].is_finite() { + hi.push(NAN3); + lo.push([0.0; 3]); + continue; + } + let (hx, lx) = split_ds(p[0] as f64 + rx); + let (hy, ly) = split_ds(p[1] as f64 + ry); + let (hz, lz) = split_ds(p[2] as f64); + hi.push([hx, hy, hz]); + lo.push([lx, ly, lz]); + } + w.points = hi; + w.points_low = lo; } if !w.fill_tris.is_empty() { - w.fill_tris = clip_triangles(&w.fill_tris, poly); + let local: Vec<[f32; 3]> = (0..w.fill_tris.len()) + .map(|i| { + let hi = w.fill_tris[i]; + let lo = w.fill_tris_low.get(i).copied().unwrap_or([0.0; 3]); + let a = abs(hi, lo); + [(a[0] - rx) as f32, (a[1] - ry) as f32, a[2] as f32] + }) + .collect(); + let clipped = clip_triangles(&local, &lpoly); + let mut hi = Vec::with_capacity(clipped.len()); + let mut lo = Vec::with_capacity(clipped.len()); + for p in clipped { + let (hx, lx) = split_ds(p[0] as f64 + rx); + let (hy, ly) = split_ds(p[1] as f64 + ry); + let (hz, lz) = split_ds(p[2] as f64); + hi.push([hx, hy, hz]); + lo.push([lx, ly, lz]); + } + w.fill_tris = hi; + w.fill_tris_low = lo; } w.key_vertices - .retain(|v| point_in_poly(v[0] as f32, v[1] as f32, poly)); - w.snap_pts.retain(|(p, _)| point_in_poly(p.x as f32, p.y as f32, poly)); + .retain(|v| point_in_poly((v[0] - rx) as f32, (v[1] - ry) as f32, &lpoly)); + w.snap_pts + .retain(|(p, _)| point_in_poly((p.x - rx) as f32, (p.y - ry) as f32, &lpoly)); w.aabb = recompute_aabb(&w.points, &w.fill_tris); } wires.retain(|w| !w.points.is_empty() || !w.fill_tris.is_empty()); } +/// Double-single split of an f64 into (high, low) f32 — mirrors +/// `WireModel::split_ds` so clipped points match the renderer's reconstruction. +fn split_ds(v: f64) -> (f32, f32) { + let high = v as f32; + (high, (v - high as f64) as f32) +} + /// Clip a hatch fill boundary to `poly`. /// /// `boundary` is a hatch's NaN-separated loops in f32 offsets from @@ -514,7 +606,7 @@ mod tests { ins.common.xdictionary_handle = Some(h_xdict); let resolved = insert_spatial_filter(&doc, &ins).expect("filter resolves"); - let poly = world_clip_polygon(resolved, &ins, [0.0, 0.0, 0.0]); + let poly = world_clip_polygon_f64(resolved, &ins, [0.0, 0.0, 0.0]); assert_eq!(poly.len(), 4); // A polyline half inside, half outside the 0..10 square. @@ -603,3 +695,4 @@ mod tests { assert!(out.iter().all(|p| p[0] <= 10.0 + 1e-3)); } } +