diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs index 691ad695d..cec941d04 100644 --- a/src/app/command_driver/clipboard.rs +++ b/src/app/command_driver/clipboard.rs @@ -269,119 +269,117 @@ impl OpenCADStudio { } /// Shared paste finalize for every paste path (PASTECLIP, PASTEORIG): - /// recreate the clipboard's dependency records and block definitions, add - /// each entity with fresh handles (optionally transformed), recreate each - /// entity's xdictionary graph (XCLIP filters etc.), and tessellate pasted - /// solids. Returns the new handles, index-aligned with the clipboard - /// (NULL where an add failed). Keeping this in one place means a new - /// cross-drawing concern is wired once, not re-implemented per command. + /// document-level paste runs in [`paste_entities_kernel`] (fresh handles, + /// dependency records, block definitions, `*D` re-point, xdictionary + /// graphs, LEADER relink); the Scene/render tail stays here (tessellation + /// bumps, group + constraint recreation, annotation-context display sync). + /// Returns the new handles, index-aligned with the clipboard + /// (NULL where an add failed). Keeping the document half in one kernel + /// means a new cross-drawing concern is wired once, not re-implemented + /// per command — and headless callers share it without a Scene. pub(crate) fn finalize_paste( &mut self, i: usize, translate: Option, ) -> Vec { - self.merge_clipboard_deps(i); - self.merge_clipboard_blocks(i); - let by_index: Vec = self - .clipboard - .clone() - .into_iter() - .map(|mut entity| { - if let Some(t) = &translate { - crate::scene::view::dispatch::apply_transform(&mut entity, t); - } - // A dimension draws from its baked `*D` block (baked in WCS), so - // give the paste its own transformed copy of that block and - // re-point it — otherwise the pasted dimension renders at the - // source location instead of the paste point. The block was - // snapshotted into the clipboard at copy time, so this works - // cross-drawing too. Mirrors the in-drawing copy. (#290, #161) - if let acadrust::EntityType::Dimension(d) = &entity { - let bn = d.base().block_name.clone(); - if !bn.trim().is_empty() { - let subs = self - .clipboard_deps - .dim_blocks - .iter() - .find(|b| b.name.eq_ignore_ascii_case(&bn)) - .map(|def| def.entities.clone()); - if let Some(subs) = subs { - let bt = translate.clone().unwrap_or( - crate::command::EntityTransform::Translate(glam::DVec3::ZERO), - ); - if let Some(new_bn) = - self.tabs[i].scene.define_transformed_block(&subs, &bt) - { - if let acadrust::EntityType::Dimension(d) = &mut entity { - d.base_mut().block_name = new_bn; - } - } - } - } - } - self.tabs[i].scene.add_entity_clone(entity) - }) - .collect(); - let annotation_delta = match translate { - Some(crate::command::EntityTransform::Translate(delta)) => delta, - _ => glam::DVec3::ZERO, - }; - self.merge_clipboard_ext_objects(i, &by_index, annotation_delta); - // Source handles stored in the clipboard map one-to-one to the freshly - // pasted handles. Use that map to reconnect LEADER -> copied annotation. - let mut handle_map = rustc_hash::FxHashMap::default(); - - for (source, &copied) in self.clipboard.iter().zip(by_index.iter()) { - if !copied.is_null() { - handle_map.insert(source.common().handle, copied); + // Per-helper narrow/keep report (Task 4): the kernel ports what + // `&mut CadDocument` can do and this shell keeps the rest — + // merge_dependencies (table records) … KEEP in kernel (verbatim + // port; pure document-table work, no Document-level free fn existed) + // merge_clipboard_blocks / define_block_raw … KEEP in kernel minus + // the trailing bump_geometry (render cache — shelled below) + // add_entity_clone … NARROW: sub-handle reset + NULL handles + + // doc.add_entity in kernel; entity_mode default, layer/app-id + // ensure, ImageDefinition auto-create, BEDIT/paper-space owner + // routing, undo recording and per-add tessellation stay caller-side + // (Scene-only; NO Scene methods refactored) + // define_transformed_block (`*D` dim blocks) … KEEP in kernel + // (verbatim port; no render-cache tail exists) + // recreate_ext_subtree / remap_object … USE existing Document-level + // free fns directly (no Scene involved) + // translate_annotation_contexts … USE existing Document-level free fn + // LEADER annotation_handle relink … KEEP in kernel (get_entity_mut); + // sync_displayed_annotation_context stays caller-side (Scene) + // recreate_groups … CALLER-SIDE (Scene::recreate_groups: undo + // recording + group-dict naming; no Document equivalent exists) + // duplicate_parametric_constraints_for … CALLER-SIDE (Scene-owned + // constraint sets; no Document equivalent exists) + // populate_missing_meshes_from_document / bumps … CALLER-SIDE + let clipboard = self.clipboard.clone(); + let deps = self.clipboard_deps.clone(); + let (by_index, handle_map) = paste_entities_kernel( + &mut self.tabs[i].scene.document, + &clipboard, + &deps, + translate.as_ref(), + ); + // Batched equivalent of `add_entity`'s per-add bump (same pattern as + // `Scene::add_entities`): fresh top-level solids tessellate once here + // instead of once per entity. Block-definition merges (and pasted + // Block/BlockEnd sentinels) need the full geometry rebuild, matching + // the `bump_geometry` tails the Scene helpers used to run. + let mut needs_geometry_bump = !deps.blocks.is_empty(); + let mut changes = Vec::new(); + for &handle in &by_index { + if handle.is_null() { + continue; + } + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some( + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) + ) { + needs_geometry_bump = true; + } else { + changes.push((handle, crate::scene::ChangeKind::Added)); } } - - let leader_links: Vec<(Handle, Handle)> = self - .clipboard - .iter() - .filter_map(|source| { - let acadrust::EntityType::Leader(leader) = source else { - return None; - }; - - let copied_leader = handle_map.get(&source.common().handle).copied()?; - let copied_annotation = handle_map - .get(&leader.annotation_handle) - .copied() - .unwrap_or(Handle::NULL); - - Some((copied_leader, copied_annotation)) - }) - .collect(); - - for (leader_handle, annotation_handle) in leader_links { - if let Some(acadrust::EntityType::Leader(leader)) = - self.tabs[i].scene.document.get_entity_mut(leader_handle) - { - leader.annotation_handle = annotation_handle; + if needs_geometry_bump { + self.tabs[i].scene.bump_geometry(); + } else if !changes.is_empty() { + self.tabs[i].scene.bump_entities(&changes); + } + // The wires tessellated above predate the freshly attached clip object + // graphs; refresh the pasted entities so XCLIP-filtered inserts + // render clipped immediately. + if !deps.ext_objects.is_empty() { + let changes: Vec<_> = by_index + .iter() + .copied() + .filter(|handle| !handle.is_null()) + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + } + // Source handles stored in the clipboard map one-to-one to the freshly + // pasted handles (see `handle_map`); refresh each pasted LEADER's + // displayed annotation context now that the relink ran in the kernel. + for &handle in by_index.iter().filter(|h| !h.is_null()) { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Leader(_)) + ) { + let _ = self.tabs[i] + .scene + .sync_displayed_annotation_context(handle); } - - let _ = self.tabs[i] - .scene - .sync_displayed_annotation_context(leader_handle); } // Recreate any group whose whole membership was copied, so a pasted // group stays grouped — cross-drawing too, since the groups were // snapshotted into the clipboard at copy time. `by_index` is aligned - // with `self.clipboard`, so the source handle of each pasted entity maps - // its clipboard clone to its new handle. Same shared `recreate_groups` + // with `self.clipboard`, so the kernel's `handle_map` maps each + // clipboard clone to its new handle. Same shared `recreate_groups` // the in-drawing COPY path uses. (#440) - if !self.clipboard_deps.groups.is_empty() { - let groups = self.clipboard_deps.groups.clone(); - self.tabs[i].scene.recreate_groups(groups, &handle_map); + if !deps.groups.is_empty() { + self.tabs[i].scene.recreate_groups(deps.groups.clone(), &handle_map); } // Constraints wholly within the pasted selection follow it. self.tabs[i] .scene .duplicate_parametric_constraints_for(&handle_map); - // Incremental: `add_entity` already tessellated every pasted top-level - // solid, and existing document solids are still cached — so only newly + // Incremental: fresh top-level solids already tessellated above, and + // existing document solids are still cached — so only newly // introduced block-definition solids need building. The full rebuild // would clear and re-tessellate the entire document (every solid in the // drawing) on each paste, which is what made a large paste stall. @@ -389,52 +387,6 @@ impl OpenCADStudio { by_index } - /// 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(crate) fn merge_clipboard_ext_objects( - &mut self, - i: usize, - by_index: &[Handle], - annotation_delta: glam::DVec3, - ) { - 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; - } - if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) { - if let Some(e) = doc.get_entity_mut(new_entity) { - e.common_mut().xdictionary_handle = Some(new_root); - } - crate::scene::annotative::translate_annotation_contexts( - doc, - new_entity, - annotation_delta, - ); - } - } - // The wires were tessellated before the filters existed; refresh only - // the freshly-pasted entities whose clip object graph was attached. - let changes: Vec<_> = by_index - .iter() - .copied() - .filter(|handle| !handle.is_null()) - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - } - /// Recreate the captured xdictionary subtrees in this document (fresh /// handles, remapped references) WITHOUT an added host entity, returning /// `entity_index → new xdictionary root`. Used by PASTEBLOCK, which folds @@ -460,6 +412,298 @@ impl OpenCADStudio { } } +/// PASTECLIP kernel: paste a clipboard payload (`entities` + `deps`, as +/// captured by [`copy_to_clipboard_kernel`]) into `doc`, optionally +/// translated, returning the new handles index-aligned with `entities` +/// (`NULL` where an add failed) plus the source-handle → pasted-handle map. +/// +/// Document-level work lives here; Scene/render concerns stay caller-side in +/// `finalize_paste` (tessellation bumps, `populate_missing_meshes`, +/// `recreate_groups`, `duplicate_parametric_constraints_for`, +/// `sync_displayed_annotation_context`, undo/dirty/selection/echo/panels). +/// See the per-helper report in `finalize_paste`. +pub(crate) fn paste_entities_kernel( + doc: &mut acadrust::CadDocument, + entities: &[acadrust::EntityType], + deps: &crate::app::ClipboardDeps, + translate: Option<&crate::command::EntityTransform>, +) -> ( + Vec, + rustc_hash::FxHashMap, +) { + use acadrust::TableEntry; + // Port of `merge_dependencies`: recreate missing table records with fresh + // handles from the target document. Pure `&mut CadDocument` work. + for rec in &deps.layers { + if !doc.layers.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.layers.add(r); + } + } + for rec in &deps.linetypes { + if !doc.line_types.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.line_types.add(r); + } + } + for rec in &deps.text_styles { + if !doc.text_styles.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.text_styles.add(r); + } + } + for rec in &deps.dim_styles { + if !doc.dim_styles.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.dim_styles.add(r); + } + } + // Port of `merge_clipboard_blocks` / `Scene::define_block_raw` minus the + // trailing `bump_geometry` (render cache — caller-side). + for def in &deps.blocks { + define_block_raw_doc(doc, &def.name, def.base_point, &def.entities); + } + + let by_index: Vec = entities + .iter() + .cloned() + .map(|mut entity| { + if let Some(t) = translate { + crate::scene::view::dispatch::apply_transform(&mut entity, t); + } + // Same `*D`-block re-point as `finalize_paste` (#290, #161): the + // baked dimension geometry lives in WCS, so the paste gets its own + // transformed copy of the snapshotted block. + if let acadrust::EntityType::Dimension(d) = &entity { + let bn = d.base().block_name.clone(); + if !bn.trim().is_empty() { + if let Some(subs) = deps + .dim_blocks + .iter() + .find(|b| b.name.eq_ignore_ascii_case(&bn)) + .map(|def| def.entities.clone()) + { + let bt = translate.cloned().unwrap_or( + crate::command::EntityTransform::Translate(glam::DVec3::ZERO), + ); + if let Some(new_bn) = define_transformed_block_doc(doc, &subs, &bt) { + if let acadrust::EntityType::Dimension(d) = &mut entity { + d.base_mut().block_name = new_bn; + } + } + } + } + } + add_entity_clone_doc(doc, entity) + }) + .collect(); + + // Port of `merge_clipboard_ext_objects` minus the trailing `bump_entities` + // (render cache — caller-side): recreate each captured xdictionary graph + // with fresh handles and re-point the pasted entity at its new root. + let annotation_delta = match translate { + Some(crate::command::EntityTransform::Translate(delta)) => *delta, + _ => glam::DVec3::ZERO, + }; + for cap in &deps.ext_objects { + let Some(&new_entity) = by_index.get(cap.entity_index) else { + continue; + }; + if new_entity.is_null() { + continue; + } + if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) { + if let Some(e) = doc.get_entity_mut(new_entity) { + e.common_mut().xdictionary_handle = Some(new_root); + } + crate::scene::annotative::translate_annotation_contexts(doc, new_entity, annotation_delta); + } + } + + // Source handles map one-to-one to the freshly pasted handles; reconnect + // LEADER -> copied annotation through the map (the + // `sync_displayed_annotation_context` refresh stays caller-side). + let mut handle_map: rustc_hash::FxHashMap = rustc_hash::FxHashMap::default(); + for (source, &copied) in entities.iter().zip(by_index.iter()) { + if !copied.is_null() { + handle_map.insert(source.common().handle, copied); + } + } + let leader_links: Vec<(Handle, Handle)> = entities + .iter() + .filter_map(|source| { + let acadrust::EntityType::Leader(leader) = source else { + return None; + }; + let copied_leader = handle_map.get(&source.common().handle).copied()?; + let copied_annotation = handle_map + .get(&leader.annotation_handle) + .copied() + .unwrap_or(Handle::NULL); + Some((copied_leader, copied_annotation)) + }) + .collect(); + for (leader_handle, annotation_handle) in leader_links { + if let Some(acadrust::EntityType::Leader(leader)) = doc.get_entity_mut(leader_handle) { + leader.annotation_handle = annotation_handle; + } + } + (by_index, handle_map) +} + +/// Document-level clone-add: the `&mut CadDocument` half of +/// `Scene::add_entity_clone` — fresh inline sub-handles (INSERT attributes, +/// 3D-polyline vertices; `reset_clone_subhandles` is `scene`-private so the +/// two lines are mirrored here), NULL top-level/owner handles, then +/// `doc.add_entity`. The `entity_mode` default (Model vs paper space) and all +/// tessellation/render caching stay caller-side in the Scene shell. +fn add_entity_clone_doc(doc: &mut acadrust::CadDocument, mut entity: acadrust::EntityType) -> Handle { + match &mut entity { + acadrust::EntityType::Insert(ins) => { + for att in ins.attributes.iter_mut() { + att.common.handle = doc.allocate_handle(); + } + } + acadrust::EntityType::Polyline3D(p) => { + for v in p.vertices.iter_mut() { + v.handle = doc.allocate_handle(); + } + } + _ => {} + } + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = Handle::NULL; + doc.add_entity(entity).unwrap_or(Handle::NULL) +} + +/// Document-level verbatim block-definition recreate: the `&mut CadDocument` +/// half of `Scene::define_block_raw` (no-op when the block exists). The +/// trailing `bump_geometry` stays caller-side. +fn define_block_raw_doc( + doc: &mut acadrust::CadDocument, + name: &str, + base_point: acadrust::types::Vector3, + entities: &[acadrust::EntityType], +) { + if name.is_empty() || doc.block_records.get(name).is_some() { + return; + } + let next = doc.next_handle(); + let br_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + let mut block_record = acadrust::tables::BlockRecord::new(name); + block_record.handle = br_handle; + block_record.block_entity_handle = block_handle; + block_record.block_end_handle = end_handle; + if doc.block_records.add(block_record).is_err() { + return; + } + let mut block = acadrust::entities::Block::new(name, base_point); + block.common.handle = block_handle; + block.common.owner_handle = br_handle; + let _ = doc.add_entity(acadrust::EntityType::Block(block)); + let mut block_end = acadrust::entities::BlockEnd::new(); + block_end.common.handle = end_handle; + block_end.common.owner_handle = br_handle; + let _ = doc.add_entity(acadrust::EntityType::BlockEnd(block_end)); + for mut entity in entities.iter().cloned() { + add_entity_clone_doc_inner(doc, &mut entity, br_handle); + let _ = doc.add_entity(entity); + } +} + +/// Shared sub-handle reset + owner stamp for block-definition members. +fn add_entity_clone_doc_inner( + doc: &mut acadrust::CadDocument, + entity: &mut acadrust::EntityType, + owner: Handle, +) { + match entity { + acadrust::EntityType::Insert(ins) => { + for att in ins.attributes.iter_mut() { + att.common.handle = doc.allocate_handle(); + } + } + acadrust::EntityType::Polyline3D(p) => { + for v in p.vertices.iter_mut() { + v.handle = doc.allocate_handle(); + } + } + _ => {} + } + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = owner; +} + +/// Document-level fresh `*D` block from `subs`: the `&mut CadDocument` +/// half of `Scene::define_transformed_block` (which has no render-cache tail, +/// so this is a verbatim port). Returns the new block name, or `None` when +/// `subs` is empty. +fn define_transformed_block_doc( + doc: &mut acadrust::CadDocument, + subs: &[acadrust::EntityType], + t: &crate::command::EntityTransform, +) -> Option { + if subs.is_empty() { + return None; + } + let mut n = 0u64; + let new_name = loop { + let cand = format!("*D{n}"); + if doc.block_records.get(&cand).is_none() { + break cand; + } + n += 1; + }; + let next = doc.next_handle(); + let br_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + let mut br = acadrust::tables::BlockRecord::new(&new_name); + br.handle = br_handle; + br.block_entity_handle = block_handle; + br.block_end_handle = end_handle; + doc.block_records.add(br).ok()?; + let mut block = acadrust::entities::Block::new(&new_name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = br_handle; + doc.add_entity(acadrust::EntityType::Block(block)).ok()?; + let mut block_end = acadrust::entities::BlockEnd::new(); + block_end.common.handle = end_handle; + block_end.common.owner_handle = br_handle; + doc.add_entity(acadrust::EntityType::BlockEnd(block_end)) + .ok()?; + for sub in subs { + let mut sub = sub.clone(); + crate::scene::view::dispatch::apply_transform(&mut sub, t); + add_entity_clone_doc_inner(doc, &mut sub, br_handle); + let _ = doc.add_entity(sub); + } + Some(new_name) +} + +/// COPYCLIP kernel: clone the selected entities out of `doc` and snapshot +/// the table records / block definitions / xdictionary graphs they depend on, +/// so a later paste can recreate them (including cross-drawing). Clipboard +/// storage (`clipboard`, `clipboard_base`, `clipboard_deps`) and the echo stay +/// caller-side in `copy_entities_to_clipboard` / `handle_copy_to_clipboard`. +pub(crate) fn copy_to_clipboard_kernel( + doc: &acadrust::CadDocument, + handles: &[Handle], +) -> (Vec, crate::app::ClipboardDeps) { + let entities: Vec<_> = handles + .iter() + .filter_map(|&handle| doc.get_entity(handle).cloned()) + .collect(); + let deps = crate::app::ClipboardDeps::capture(doc, &entities); + (entities, deps) +} + fn recreate_ext_subtree( doc: &mut acadrust::CadDocument, cap: &crate::app::ClipExtObjects, @@ -534,7 +778,7 @@ pub(crate) fn remap_ext_subtree_reference( /// 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( +pub(crate) fn remap_object( obj: &mut acadrust::objects::ObjectType, new_handle: acadrust::Handle, remap: &std::collections::HashMap, @@ -646,3 +890,444 @@ fn remap_object( _ => {} } } + +#[cfg(test)] +mod clipboard_xdict_clone_tests { + use super::recreate_ext_subtree; + use super::remap_object; + use acadrust::Handle; + // Same re-export path `commands/blocks.rs:356` uses — this import proves + // the `mod.rs` re-export still covers external callers. + use super::super::remap_ext_subtree_reference; + use acadrust::objects::{ObjectType, XRecordValue}; + + /// A dictionary root pointing at one XRecord leaf that references the + /// source entity — the minimal shape of a captured XCLIP filter graph. + fn xdict_capture_fixture() -> crate::app::ClipExtObjects { + let mut src = acadrust::CadDocument::new(); + let src_entity = src.allocate_handle(); + let src_root = src.allocate_handle(); + let src_leaf = src.allocate_handle(); + + let mut dict = acadrust::objects::Dictionary::new(); + dict.handle = src_root; + dict.owner = src_entity; + dict.add_entry("XCLIP", src_leaf); + + let mut xrec = acadrust::objects::XRecord::new(); + xrec.handle = src_leaf; + xrec.owner = src_root; + xrec.entries.push(acadrust::objects::XRecordEntry::new( + 340, + XRecordValue::Handle(src_entity), + )); + + crate::app::ClipExtObjects { + entity_index: 0, + src_entity_handle: src_entity, + root: src_root, + objects: vec![ + (src_root, ObjectType::Dictionary(dict)), + (src_leaf, ObjectType::XRecord(xrec)), + ], + annotation_scales: Vec::new(), + } + } + + fn leaf_of(doc: &acadrust::CadDocument, root: Handle) -> Handle { + match doc.objects.get(&root) { + Some(ObjectType::Dictionary(d)) => { + assert_eq!(d.entries.len(), 1); + d.entries[0].1 + } + other => panic!("expected recreated Dictionary root, got {other:?}"), + } + } + + /// Headless cross-drawing clone: recreate a captured xdictionary subtree + /// in a fresh document and assert every remapped handle resolves. + #[test] + fn cross_drawing_clone_remaps_xdict_handles() { + let cap = xdict_capture_fixture(); + let mut dst = acadrust::CadDocument::new(); + let before: Vec = dst.objects.keys().copied().collect(); + let dst_entity = dst.allocate_handle(); + + let new_root = recreate_ext_subtree(&mut dst, &cap, Some(dst_entity)) + .expect("recreated xdictionary root"); + assert!(!new_root.is_null()); + assert!( + !before.contains(&new_root), + "clone must use a fresh handle" + ); + assert_eq!(dst.objects.len(), before.len() + 2); + + let leaf = leaf_of(&dst, new_root); + assert!(!before.contains(&leaf), "leaf must use a fresh handle"); + match dst.objects.get(&new_root) { + Some(ObjectType::Dictionary(d)) => { + assert_eq!(d.handle, new_root); + assert_eq!(d.owner, dst_entity); + } + other => panic!("expected recreated Dictionary root, got {other:?}"), + } + match dst.objects.get(&leaf) { + Some(ObjectType::XRecord(x)) => { + assert_eq!(x.handle, leaf); + assert_eq!(x.owner, new_root); + assert_eq!(x.entries.len(), 1); + assert!( + matches!(&x.entries[0].value, XRecordValue::Handle(h) if *h == dst_entity), + "leaf entity reference must point at the pasted entity" + ); + } + other => panic!("expected recreated XRecord leaf, got {other:?}"), + } + } + + /// PASTEBLOCK-style two-phase clone: recreate without a host entity, then + /// re-point the subtree at the final block-owned handle. + #[test] + fn cross_drawing_clone_two_phase_remap_resolves_reference() { + let cap = xdict_capture_fixture(); + let mut dst = acadrust::CadDocument::new(); + let before_len = dst.objects.len(); + + let root = + recreate_ext_subtree(&mut dst, &cap, None).expect("recreated xdictionary root"); + let leaf = leaf_of(&dst, root); + match dst.objects.get(&leaf) { + Some(ObjectType::XRecord(x)) => assert!( + matches!(&x.entries[0].value, XRecordValue::Handle(h) if *h == cap.src_entity_handle), + "unbound clone must keep the source-space reference" + ), + other => panic!("expected recreated XRecord leaf, got {other:?}"), + } + + let target = dst.allocate_handle(); + remap_ext_subtree_reference(&mut dst, root, cap.src_entity_handle, target); + assert_eq!(dst.objects.len(), before_len + 2); + match dst.objects.get(&leaf) { + Some(ObjectType::XRecord(x)) => assert!( + matches!(&x.entries[0].value, XRecordValue::Handle(h) if *h == target), + "remapped leaf must point at the block-owned entity" + ), + other => panic!("expected recreated XRecord leaf, got {other:?}"), + } + } + + /// The widened `pub(crate)` kernel rewrites a cloned dictionary onto + /// fresh handles through the remap table. + #[test] + fn remap_object_rewrites_dictionary_handles() { + use std::collections::HashMap; + let mut scratch = acadrust::CadDocument::new(); + let old_self = scratch.allocate_handle(); + let old_owner = scratch.allocate_handle(); + let old_child = scratch.allocate_handle(); + let new_self = scratch.allocate_handle(); + let new_owner = scratch.allocate_handle(); + let new_child = scratch.allocate_handle(); + + let mut dict = acadrust::objects::Dictionary::new(); + dict.handle = old_self; + dict.owner = old_owner; + dict.add_entry("K", old_child); + let mut obj = ObjectType::Dictionary(dict); + let remap = HashMap::from([(old_owner, new_owner), (old_child, new_child)]); + remap_object(&mut obj, new_self, &remap); + match obj { + ObjectType::Dictionary(d) => { + assert_eq!(d.handle, new_self); + assert_eq!(d.owner, new_owner); + assert_eq!(d.entries[0].1, new_child); + } + other => panic!("expected Dictionary, got {other:?}"), + } + } +} + +#[cfg(test)] +mod copy_to_clipboard_kernel_tests { + use super::copy_to_clipboard_kernel; + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + #[test] + fn clones_entities_and_captures_layer_deps() { + let mut doc = acadrust::CadDocument::new(); + doc.layers + .add(acadrust::tables::Layer::new("WALLS")) + .expect("add layer"); + let a = doc.add_entity(line_on("WALLS")).unwrap(); + let b = doc.add_entity(line_on("WALLS")).unwrap(); + + let (entities, deps) = copy_to_clipboard_kernel(&doc, &[a, b]); + + assert_eq!(entities.len(), 2); + assert!(entities.iter().all(|e| e.common().layer == "WALLS")); + let handles: Vec<_> = entities.iter().map(|e| e.common().handle).collect(); + assert!(handles.contains(&a)); + assert!(handles.contains(&b)); + assert!(deps.layers.iter().any(|l| l.name == "WALLS")); + } + + #[test] + fn skips_handles_missing_from_the_document() { + let mut doc = acadrust::CadDocument::new(); + let kept = doc.add_entity(line_on("0")).unwrap(); + let missing = doc.allocate_handle(); + + let (entities, _) = copy_to_clipboard_kernel(&doc, &[kept, missing]); + + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].common().handle, kept); + } + + #[test] + fn empty_selection_yields_empty_payload() { + let doc = acadrust::CadDocument::new(); + + let (entities, deps) = copy_to_clipboard_kernel(&doc, &[]); + + assert!(entities.is_empty()); + assert!(deps.layers.is_empty()); + assert!(deps.blocks.is_empty()); + } +} + +#[cfg(test)] +mod paste_entities_kernel_tests { + use super::{copy_to_clipboard_kernel, paste_entities_kernel}; + use acadrust::Handle; + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + #[test] + fn pastes_with_fresh_handles_and_translate() { + let mut seed = acadrust::CadDocument::new(); + seed.layers + .add(acadrust::tables::Layer::new("WALLS")) + .expect("add layer"); + // Position the seed line away from the origin so the translate is visible. + let mut seed_line = acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ); + seed_line.common.layer = "WALLS".to_string(); + let seed_handle = seed + .add_entity(acadrust::EntityType::Line(seed_line)) + .unwrap(); + let (entities, deps) = copy_to_clipboard_kernel(&seed, &[seed_handle]); + + let mut dst = acadrust::CadDocument::new(); + let delta = glam::DVec3::new(5.0, 7.0, 0.0); + let translate = crate::command::EntityTransform::Translate(delta); + let (handles, handle_map) = + paste_entities_kernel(&mut dst, &entities, &deps, Some(&translate)); + + assert_eq!(handles.len(), 1); + let pasted = handles[0]; + assert!(!pasted.is_null()); + assert_ne!(pasted, seed_handle, "paste must use a fresh handle"); + assert_eq!(handle_map.get(&seed_handle), Some(&pasted)); + let line = match dst.get_entity(pasted) { + Some(acadrust::EntityType::Line(l)) => l, + other => panic!("expected pasted Line, got {other:?}"), + }; + assert!((line.start.x - 5.0).abs() < 1e-9, "start.x = {}", line.start.x); + assert!((line.start.y - 7.0).abs() < 1e-9, "start.y = {}", line.start.y); + assert!((line.end.x - 15.0).abs() < 1e-9, "end.x = {}", line.end.x); + // The layer dependency must have been recreated in the target document. + assert!(dst.layers.contains("WALLS")); + assert_eq!(line.common.layer, "WALLS"); + } + + #[test] + fn remaps_leader_annotation_link_to_pasted_handles() { + let mut seed = acadrust::CadDocument::new(); + let note = seed + .add_entity(acadrust::EntityType::MText(acadrust::entities::MText::new())) + .unwrap(); + let mut leader = acadrust::entities::Leader::default(); + leader.annotation_handle = note; + let leader_handle = seed + .add_entity(acadrust::EntityType::Leader(leader)) + .unwrap(); + let (entities, deps) = copy_to_clipboard_kernel(&seed, &[leader_handle, note]); + + let mut dst = acadrust::CadDocument::new(); + let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None); + + assert_eq!(handles.len(), 2); + let pasted_leader = match dst.get_entity(handles[0]) { + Some(acadrust::EntityType::Leader(l)) => l, + other => panic!("expected pasted Leader, got {other:?}"), + }; + assert_eq!( + pasted_leader.annotation_handle, handles[1], + "leader must point at the pasted annotation, not the source handle" + ); + } + + #[test] + fn recreates_ext_subtree_and_repoints_xdictionary() { + // Minimal XCLIP-shaped capture: a dictionary root over one XRecord leaf + // that references the source entity (mirrors clipboard_xdict_clone_tests). + use acadrust::objects::{Dictionary, ObjectType, XRecord, XRecordEntry, XRecordValue}; + let mut seed = acadrust::CadDocument::new(); + let seed_entity = seed.add_entity(line_on("0")).unwrap(); + let src_root = seed.allocate_handle(); + let src_leaf = seed.allocate_handle(); + let mut dict = Dictionary::new(); + dict.handle = src_root; + dict.owner = seed_entity; + dict.add_entry("XCLIP", src_leaf); + let mut xrec = XRecord::new(); + xrec.handle = src_leaf; + xrec.owner = src_root; + xrec.entries.push(XRecordEntry::new( + 340, + XRecordValue::Handle(seed_entity), + )); + seed.objects.insert(src_root, ObjectType::Dictionary(dict)); + seed.objects.insert(src_leaf, ObjectType::XRecord(xrec)); + if let Some(e) = seed.get_entity_mut(seed_entity) { + e.common_mut().xdictionary_handle = Some(src_root); + } + let (entities, deps) = copy_to_clipboard_kernel(&seed, &[seed_entity]); + assert_eq!(deps.ext_objects.len(), 1, "capture must snapshot the xdict"); + + let mut dst = acadrust::CadDocument::new(); + let before: Vec = dst.objects.keys().copied().collect(); + let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None); + + assert_eq!(handles.len(), 1); + let pasted = handles[0]; + let new_root = dst + .get_entity(pasted) + .and_then(|e| e.common().xdictionary_handle) + .expect("pasted entity must point at a recreated xdictionary root"); + // Handles are document-local: freshness is judged within the target + // document, not against the source document's numbers. + assert!( + !before.contains(&new_root), + "recreated root must use a fresh handle in the target document" + ); + let leaf = match dst.objects.get(&new_root) { + Some(ObjectType::Dictionary(d)) => { + assert_eq!(d.entries.len(), 1); + d.entries[0].1 + } + other => panic!("expected recreated Dictionary root, got {other:?}"), + }; + match dst.objects.get(&leaf) { + Some(ObjectType::XRecord(x)) => assert!( + matches!(&x.entries[0].value, XRecordValue::Handle(h) if *h == pasted), + "leaf reference must point at the pasted entity" + ), + other => panic!("expected recreated XRecord leaf, got {other:?}"), + } + } +} + +#[cfg(test)] +mod headless_reuse_proof_tests { + // Task 5 reuse proof — headless only. This module constructs NO + // `OpenCADStudio` value and touches no UI/scene state: only + // `acadrust::CadDocument`, entities, and the shared kernels + // (`copy_to_clipboard_kernel`, `paste_entities_kernel`, + // `match_layer_kernel`, `match_properties_kernel` + `MatchOpts`). + use super::{copy_to_clipboard_kernel, paste_entities_kernel}; + use crate::entities::match_props::{MatchOpts, match_layer_kernel, match_properties_kernel}; + + #[test] + fn headless_copy_paste_then_match_kernels_share_one_path() { + // Phase 1: copy in doc A, paste into doc B with a translate. + let mut doc_a = acadrust::CadDocument::new(); + doc_a + .layers + .add(acadrust::tables::Layer::new("WALLS")) + .expect("add layer"); + let mut seed_line = acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ); + seed_line.common.layer = "WALLS".to_string(); + let seed_handle = doc_a + .add_entity(acadrust::EntityType::Line(seed_line)) + .unwrap(); + + let (entities, deps) = copy_to_clipboard_kernel(&doc_a, &[seed_handle]); + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].common().layer, "WALLS"); + + let mut doc_b = acadrust::CadDocument::new(); + let translate = + crate::command::EntityTransform::Translate(glam::DVec3::new(5.0, 7.0, 0.0)); + let (handles, handle_map) = + paste_entities_kernel(&mut doc_b, &entities, &deps, Some(&translate)); + + assert_eq!(handles.len(), 1); + let pasted = handles[0]; + assert!(!pasted.is_null()); + assert_ne!(pasted, seed_handle, "paste must use a fresh handle"); + assert_eq!(handle_map.get(&seed_handle), Some(&pasted)); + assert!(doc_b.layers.contains("WALLS"), "layer dep must follow"); + match doc_b.get_entity(pasted) { + Some(acadrust::EntityType::Line(l)) => { + assert_eq!(l.common.layer, "WALLS"); + assert!((l.start.x - 5.0).abs() < 1e-9, "start.x = {}", l.start.x); + assert!((l.start.y - 7.0).abs() < 1e-9, "start.y = {}", l.start.y); + assert!((l.end.x - 15.0).abs() < 1e-9, "end.x = {}", l.end.x); + } + other => panic!("expected pasted Line, got {other:?}"), + } + + // Phase 2: match kernels on a src/dst pair in the same headless doc. + let mut src_line = acadrust::entities::Line::new(); + src_line.common.layer = "WALLS".to_string(); + src_line.common.linetype_scale = 2.5; + src_line.thickness = 1.25; + let src = doc_b + .add_entity(acadrust::EntityType::Line(src_line)) + .unwrap(); + let mut dst_line = acadrust::entities::Line::new(); + dst_line.common.layer = "0".to_string(); + dst_line.common.linetype_scale = 1.0; + dst_line.thickness = 0.0; + let dst = doc_b + .add_entity(acadrust::EntityType::Line(dst_line)) + .unwrap(); + + let count = match_layer_kernel(&mut doc_b, &[dst], src).expect("match layer"); + assert_eq!(count, 1); + assert_eq!( + doc_b.get_entity(dst).unwrap().common().layer, + "WALLS", + "match_layer_kernel must transfer the layer" + ); + + let src_clone = doc_b.get_entity(src).cloned().unwrap(); + { + let dst_entity = doc_b.get_entity_mut(dst).unwrap(); + match_properties_kernel(&src_clone, dst_entity, &MatchOpts::all()); + } + match doc_b.get_entity(dst) { + Some(acadrust::EntityType::Line(l)) => { + assert_eq!(l.common.layer, "WALLS"); + assert!((l.common.linetype_scale - 2.5).abs() < 1e-9); + assert!((l.thickness - 1.25).abs() < 1e-9); + } + other => panic!("expected matched Line, got {other:?}"), + } + } +} diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 82677691b..7230b714b 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -51,14 +51,16 @@ //! dynamic dimensions, and the constraint-inference entry points //! (`apply_continuous_constraints`, `apply_inferred_constraints`) shared by //! the `constraint.rs` arms. -//! - `property_match.rs` — MATCHPROP/LAYMATCH + `match_special_props`. +//! - `property_match.rs` — MATCHPROP/LAYMATCH arms (special-prop kernels live +//! in `crate::entities::match_props`). //! - `entity_commit.rs` — entity/dimension/hatch/block commit arms. //! - `solid3d.rs` — extrude/thicken/presspull/revolve/sweep/loft/shell/boolean/slice. //! - `viewport.rs` — MVIEW/layout/plot/zoom/print arms. //! - `transform.rs` — MOVE/COPY/ALIGN/STRETCH arms. //! - `clipboard.rs` — COPYCLIP/PASTECLIP/PASTEORIG/PASTEBLOCK arms, //! clipboard merge + xdict helpers; re-exports -//! `remap_ext_subtree_reference` for `app::commands::blocks`. +//! `remap_ext_subtree_reference` and `copy_to_clipboard_kernel` +//! for `app::commands::blocks`. //! - `modify.rs` — LENGTHEN/DIVIDE/MEASURE/PEDIT/JOIN/BREAK/HATCHEDIT arms + //! `apply_pedit_result`. (Entity BREAK itself delegates to //! `break_cmd::break_entity`; the DIMBREAK free helpers live in `dim_edit`.) @@ -92,7 +94,7 @@ mod text_edit; mod constraint; mod dim_edit; -pub(crate) use clipboard::remap_ext_subtree_reference; +pub(crate) use clipboard::{copy_to_clipboard_kernel, remap_ext_subtree_reference}; impl OpenCADStudio { /// Applies one command result, then — when that result ended the active diff --git a/src/app/command_driver/property_match.rs b/src/app/command_driver/property_match.rs index 77c9e555f..9284a48ce 100644 --- a/src/app/command_driver/property_match.rs +++ b/src/app/command_driver/property_match.rs @@ -20,11 +20,13 @@ impl OpenCADStudio { .push_info(crate::t!("No editable objects selected.").as_ref()); } else if let Some(layer) = src_layer { self.push_undo_snapshot(i, "LAYMATCH"); - for h in &dest { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { - e.as_entity_mut().set_layer(layer.clone()); - } - } + // Pure mutation lives in the shared kernel; lock-filter (above) + // plus undo, invalidate, dirty and echo stay caller-side. + let _ = crate::entities::match_props::match_layer_kernel( + &mut self.tabs[i].scene.document, + &dest, + src, + ); // New layer changes the baked by-layer colour/linetype/ // lineweight — re-tessellate the moved entities so they // repaint immediately (issue #231 class). @@ -53,10 +55,6 @@ impl OpenCADStudio { // The command stays active after each apply so more targets // can keep being picked; Enter / Esc ends it (#362). let src_clone = self.tabs[i].scene.document.get_entity(src).cloned(); - let src_common = src_clone.as_ref().map(|e| e.common().clone()); - let thickness = src_clone - .as_ref() - .and_then(crate::scene::view::dispatch::entity_thickness); // This application record is the hatch background colour. // Keep the outer Option to distinguish "source is not a // hatch" from "source hatch has no background". @@ -83,41 +81,24 @@ impl OpenCADStudio { }) .map(|e| crate::entities::dim_override::pairs(&e.common().extended_data)); - if let Some(common) = src_common { + if src_clone.is_some() { + // Entity-level transfer lives in the shared kernel so headless + // callers share it; the `&mut Document` xdata paths, undo, + // tessellation, selection and echo stay caller-side here. + // `all()` preserves the interactive behaviour; the xdata paths + // below honour the matching flags for future narrow callers. + let match_opts = crate::entities::match_props::MatchOpts::all(); self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| { let mut is_dim = false; let mut is_hatch = false; - if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) { - e.as_entity_mut().set_layer(common.layer.clone()); - crate::scene::view::dispatch::apply_color(e, common.color); - crate::scene::view::dispatch::apply_line_weight(e, common.line_weight); - { - let dst_common = e.common_mut(); - dst_common.linetype = common.linetype.clone(); - dst_common.linetype_handle = common.linetype_handle; - dst_common.linetype_scale = common.linetype_scale; - dst_common.transparency = common.transparency.clone(); - dst_common.color_name = common.color_name.clone(); - dst_common.color_book_handle = common.color_book_handle; - dst_common.full_visual_style_handle = common.full_visual_style_handle; - dst_common.face_visual_style_handle = common.face_visual_style_handle; - dst_common.edge_visual_style_handle = common.edge_visual_style_handle; - dst_common.material_flags = common.material_flags; - dst_common.material_handle = common.material_handle; - dst_common.shadow_flags = common.shadow_flags; - dst_common.plotstyle_flags = common.plotstyle_flags; - dst_common.plotstyle_handle = common.plotstyle_handle; - } - if let Some(value) = thickness { - crate::scene::view::dispatch::set_entity_thickness(e, value); - } - if let Some(se) = &src_clone { + if let Some(se) = &src_clone { + if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) { is_dim = matches!(e, acadrust::EntityType::Dimension(_)); is_hatch = matches!(e, acadrust::EntityType::Hatch(_)); - match_special_props(se, e); + crate::entities::match_props::match_properties_kernel(se, e, &match_opts); } } - if is_hatch { + if is_hatch && match_opts.copy_hatch_background { if let Some(values) = &hatch_background_xdata { crate::scene::view::dispatch::set_entity_xdata( &mut app.tabs[i].scene.document, @@ -129,26 +110,25 @@ impl OpenCADStudio { } // Dim-style overrides follow the style for dimension / // leader destinations — through set_entity_xdata so no - // stale raw record survives. - if dstyle_xdata.is_some() - || matches!( + // stale raw record survives. Caller-side: needs `&mut Document`. + // (Collapsed: the old outer `dstyle_xdata.is_some() || dst-is-dim` + // gate is subsumed — the replace only ever ran when both dst + // and src are dim/leader.) + if match_opts.copy_dim_overrides + && matches!( app.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) ) - { - if matches!( - app.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) - ) && matches!( + && matches!( src_clone, Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) - ) { - crate::entities::dim_override::replace( - &mut app.tabs[i].scene.document, - handle, - dstyle_xdata.clone().unwrap_or_default(), - ); - } + ) + { + crate::entities::dim_override::replace( + &mut app.tabs[i].scene.document, + handle, + dstyle_xdata.clone().unwrap_or_default(), + ); } // A restyled dimension renders from its baked *D block — // drop the stale block so the new style shows (#398). @@ -185,428 +165,3 @@ impl OpenCADStudio { } } - -/// MATCHPROP special properties (#281): copy every STYLE-affecting field from -/// `src` to `dst` when the destination supports it — never content (text -/// strings, block names) or placement (positions, rotations of the object -/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses -/// Dimension / Leader / Tolerance. -fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) { - use acadrust::EntityType as E; - - // Text-ish source formatting. - let text_fmt = match src { - E::Text(t) => Some(( - t.style.clone(), - t.height, - Some(t.width_factor), - Some(t.oblique_angle), - )), - E::MText(m) => Some((m.style.clone(), m.height, None, None)), - E::AttributeDefinition(a) => Some(( - a.text_style.clone(), - a.height, - Some(a.width_factor), - Some(a.oblique_angle), - )), - E::AttributeEntity(a) => Some(( - a.text_style.clone(), - a.height, - Some(a.width_factor), - Some(a.oblique_angle), - )), - _ => None, - }; - if let Some((style, height, wf, ob)) = text_fmt { - match dst { - E::Text(t) => { - t.style = style.clone(); - t.height = height; - if let Some(value) = wf { - t.width_factor = value; - } - if let Some(value) = ob { - t.oblique_angle = value; - } - } - E::MText(m) => { - m.style = style.clone(); - m.height = height; - } - E::AttributeDefinition(a) => { - a.text_style = style.clone(); - a.height = height; - if let Some(value) = wf { - a.width_factor = value; - } - if let Some(value) = ob { - a.oblique_angle = value; - } - } - E::AttributeEntity(a) => { - a.text_style = style; - a.height = height; - if let Some(value) = wf { - a.width_factor = value; - } - if let Some(value) = ob { - a.oblique_angle = value; - } - } - _ => {} - } - } - // MText-only extras (line spacing, background fill). - if let (E::MText(sm), E::MText(dm)) = (src, dst as &mut E) { - dm.drawing_direction = sm.drawing_direction; - dm.line_spacing_factor = sm.line_spacing_factor; - dm.line_spacing_style = sm.line_spacing_style; - dm.background_fill_flags = sm.background_fill_flags; - dm.background_scale = sm.background_scale; - dm.background_color = sm.background_color; - dm.background_transparency = sm.background_transparency; - } - - // Dimension style name crosses the three dim-styled families. - let dim_style = match src { - E::Dimension(d) => Some(d.base().style_name.clone()), - E::Leader(l) => Some(l.dimension_style.clone()), - E::Tolerance(t) => Some(t.dimension_style_name.clone()), - _ => None, - }; - if let Some(ds) = dim_style { - match dst { - E::Dimension(d) => d.base_mut().style_name = ds, - E::Leader(l) => l.dimension_style = ds, - E::Tolerance(t) => t.dimension_style_name = ds, - _ => {} - } - } - - // Hatch pattern / gradient — everything but the boundary. - if let (E::Hatch(sh), E::Hatch(dh)) = (src, dst as &mut E) { - dh.pattern = sh.pattern.clone(); - dh.pattern_type = sh.pattern_type; - dh.pattern_angle = sh.pattern_angle; - dh.pattern_scale = sh.pattern_scale; - dh.is_solid = sh.is_solid; - dh.is_double = sh.is_double; - dh.style = sh.style; - dh.gradient_color = sh.gradient_color.clone(); - } - - // Polyline display style crosses lightweight and legacy 2D polylines. - // Per-vertex/tapered widths are resampled over the destination vertices; - // they must not be flattened into the source's constant-width field. - if let Some(style) = PolylineMatchStyle::from_entity(src) { - style.apply_to(dst); - } - - if let (E::Leader(sl), E::Leader(dl)) = (src, dst as &mut E) { - dl.arrow_enabled = sl.arrow_enabled; - dl.path_type = sl.path_type; - dl.hookline_direction = sl.hookline_direction; - dl.hookline_enabled = sl.hookline_enabled; - dl.override_color = sl.override_color; - dl.dimension_gap = sl.dimension_gap; - dl.arrowhead_type = sl.arrowhead_type; - dl.arrow_size = sl.arrow_size; - dl.byblock_color = sl.byblock_color; - } - if let (E::Tolerance(st), E::Tolerance(dt)) = (src, dst as &mut E) { - dt.dimension_style_handle = st.dimension_style_handle; - dt.text_height = st.text_height; - dt.dimension_gap = st.dimension_gap; - } - - // Paper-space geometry and view position stay; viewport display/plot - // styling and effective scale follow the source. - if let (E::Viewport(sv), E::Viewport(dv)) = (src, dst as &mut E) { - let scale = crate::scene::vp_effective_scale(sv.custom_scale, sv.view_height, sv.height); - dv.custom_scale = scale; - if scale.abs() > 1e-9 { - dv.view_height = dv.height / scale; - } - dv.status.locked = sv.status.locked; - dv.status.hide_plot = sv.status.hide_plot; - dv.render_mode = sv.render_mode; - dv.style_sheet = sv.style_sheet.clone(); - dv.shade_plot_mode = sv.shade_plot_mode; - dv.background_handle = sv.background_handle; - dv.shade_plot_handle = sv.shade_plot_handle; - dv.visual_style_handle = sv.visual_style_handle; - dv.default_lighting = sv.default_lighting; - dv.default_lighting_type = sv.default_lighting_type; - dv.brightness = sv.brightness; - dv.contrast = sv.contrast; - dv.ambient_color = sv.ambient_color; - } - - if let (E::Table(st), E::Table(dt)) = (src, dst as &mut E) { - dt.table_style_handle = st.table_style_handle; - dt.base_style = st.base_style.clone(); - dt.override_flag = st.override_flag; - dt.override_border_color = st.override_border_color; - dt.override_border_line_weight = st.override_border_line_weight; - dt.override_border_visibility = st.override_border_visibility; - dt.legacy_style_override = st.legacy_style_override.clone(); - dt.legacy_border_colors = st.legacy_border_colors.clone(); - dt.legacy_border_line_weights = st.legacy_border_line_weights.clone(); - dt.legacy_border_visibility = st.legacy_border_visibility.clone(); - } - - if let (E::MLine(sm), E::MLine(dm)) = (src, dst as &mut E) { - dm.style_handle = sm.style_handle; - dm.style_name = sm.style_name.clone(); - dm.style_element_count = sm.style_element_count; - dm.justification = sm.justification; - dm.scale_factor = sm.scale_factor; - // Stored segment offsets bake the old style into each vertex. Empty - // data deliberately selects the renderer's style-derived fallback. - for vertex in &mut dm.vertices { - vertex.segments.clear(); - } - } - - // MultiLeader style + every style-affecting override; content and - // geometry (leader points, text, block handle) stay. - if let (E::MultiLeader(sm), E::MultiLeader(dm)) = (src, dst as &mut E) { - dm.style_handle = sm.style_handle; - dm.path_type = sm.path_type; - dm.line_color = sm.line_color; - dm.line_type_handle = sm.line_type_handle; - dm.line_weight = sm.line_weight; - dm.enable_landing = sm.enable_landing; - dm.enable_dogleg = sm.enable_dogleg; - dm.dogleg_length = sm.dogleg_length; - dm.arrowhead_handle = sm.arrowhead_handle; - dm.arrowhead_size = sm.arrowhead_size; - dm.text_style_handle = sm.text_style_handle; - dm.text_color = sm.text_color; - dm.text_frame = sm.text_frame; - dm.text_height = sm.text_height; - dm.text_left_attachment = sm.text_left_attachment; - dm.text_right_attachment = sm.text_right_attachment; - dm.text_top_attachment = sm.text_top_attachment; - dm.text_bottom_attachment = sm.text_bottom_attachment; - dm.text_attachment_direction = sm.text_attachment_direction; - dm.text_attachment_point = sm.text_attachment_point; - dm.text_alignment = sm.text_alignment; - dm.text_angle_type = sm.text_angle_type; - dm.text_direction_negative = sm.text_direction_negative; - dm.text_align_in_ipe = sm.text_align_in_ipe; - dm.block_content_color = sm.block_content_color; - dm.block_connection_type = sm.block_connection_type; - dm.block_scale = sm.block_scale; - dm.scale_factor = sm.scale_factor; - dm.property_override_flags = sm.property_override_flags; - dm.enable_annotation_scale = sm.enable_annotation_scale; - dm.extend_leader_to_text = sm.extend_leader_to_text; - dm.arrowhead_overrides = sm.arrowhead_overrides.clone(); - - let sc = &sm.context; - let dc = &mut dm.context; - dc.scale_factor = sc.scale_factor; - dc.text_height = sc.text_height; - dc.text_width = sc.text_width; - dc.text_boundary_height = sc.text_boundary_height; - dc.line_spacing_factor = sc.line_spacing_factor; - dc.line_spacing_style = sc.line_spacing_style; - dc.text_color = sc.text_color; - dc.text_attachment_point = sc.text_attachment_point; - dc.text_flow_direction = sc.text_flow_direction; - dc.text_alignment = sc.text_alignment; - dc.text_left_attachment = sc.text_left_attachment; - dc.text_right_attachment = sc.text_right_attachment; - dc.text_top_attachment = sc.text_top_attachment; - dc.text_bottom_attachment = sc.text_bottom_attachment; - dc.text_height_automatic = sc.text_height_automatic; - dc.word_break = sc.word_break; - dc.text_style_handle = sc.text_style_handle; - dc.block_content_scale = sc.block_content_scale; - dc.block_content_color = sc.block_content_color; - dc.block_connection_type = sc.block_connection_type; - dc.column_type = sc.column_type; - dc.column_width = sc.column_width; - dc.column_gutter = sc.column_gutter; - dc.column_flow_reversed = sc.column_flow_reversed; - dc.column_sizes = sc.column_sizes.clone(); - dc.background_fill_enabled = sc.background_fill_enabled; - dc.background_mask_fill_on = sc.background_mask_fill_on; - dc.background_fill_color = sc.background_fill_color; - dc.background_scale_factor = sc.background_scale_factor; - dc.background_transparency = sc.background_transparency; - dc.arrowhead_size = sc.arrowhead_size; - dc.landing_gap = sc.landing_gap; - dc.scale_handle = sc.scale_handle; - } - - // External-reference identity, placement and clip geometry stay. Only - // display controls/appearance are matched. - if let (E::RasterImage(si), E::RasterImage(di)) = (src, dst as &mut E) { - di.flags = si.flags; - di.clipping_enabled = si.clipping_enabled; - di.brightness = si.brightness; - di.contrast = si.contrast; - di.fade = si.fade; - di.clip_boundary.clip_mode = si.clip_boundary.clip_mode; - } - if let (E::Wipeout(sw), E::Wipeout(dw)) = (src, dst as &mut E) { - dw.flags = sw.flags; - dw.clipping_enabled = sw.clipping_enabled; - dw.brightness = sw.brightness; - dw.contrast = sw.contrast; - dw.fade = sw.fade; - dw.clip_mode = sw.clip_mode; - } - if let (E::Underlay(su), E::Underlay(du)) = (src, dst as &mut E) { - du.flags = su.flags; - du.contrast = su.contrast; - du.fade = su.fade; - du.clip_inverted = su.clip_inverted; - } -} - -struct PolylineMatchStyle { - plinegen: bool, - widths: Vec<(f64, f64)>, -} - -impl PolylineMatchStyle { - fn from_entity(entity: &acadrust::EntityType) -> Option { - use acadrust::EntityType as E; - - match entity { - E::LwPolyline(poly) => { - let widths = if poly.vertices.is_empty() { - vec![(poly.constant_width, poly.constant_width)] - } else { - poly.vertices - .iter() - .map(|v| { - ( - width_or_default(v.start_width, poly.constant_width), - width_or_default(v.end_width, poly.constant_width), - ) - }) - .collect() - }; - Some(Self { - plinegen: poly.plinegen, - widths, - }) - } - E::Polyline2D(poly) => { - let widths = if poly.vertices.is_empty() { - vec![(poly.start_width, poly.end_width)] - } else { - poly.vertices - .iter() - .map(|v| { - ( - width_or_default(v.start_width, poly.start_width), - width_or_default(v.end_width, poly.end_width), - ) - }) - .collect() - }; - Some(Self { - plinegen: poly.flags.bits() - & acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits() - != 0, - widths, - }) - } - _ => None, - } - } - - fn apply_to(&self, entity: &mut acadrust::EntityType) { - use acadrust::EntityType as E; - - match entity { - E::LwPolyline(poly) => { - poly.plinegen = self.plinegen; - let sampled = resample_widths(&self.widths, poly.vertices.len()); - let first = sampled.first().copied().unwrap_or((0.0, 0.0)); - let can_use_constant = widths_are_same(&sampled) && nearly_equal(first.0, first.1); - poly.constant_width = if can_use_constant { first.0 } else { 0.0 }; - for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { - if can_use_constant { - vertex.start_width = 0.0; - vertex.end_width = 0.0; - } else { - vertex.start_width = start; - vertex.end_width = end; - } - } - } - E::Polyline2D(poly) => { - let mut bits = poly.flags.bits(); - let flag = acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits(); - if self.plinegen { - bits |= flag; - } else { - bits &= !flag; - } - poly.flags = acadrust::entities::PolylineFlags::from_bits(bits); - let sampled = resample_widths(&self.widths, poly.vertices.len()); - let first = sampled.first().copied().unwrap_or((0.0, 0.0)); - let can_use_defaults = widths_are_same(&sampled); - if can_use_defaults { - poly.start_width = first.0; - poly.end_width = first.1; - } else { - poly.start_width = 0.0; - poly.end_width = 0.0; - } - for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { - if can_use_defaults { - vertex.start_width = 0.0; - vertex.end_width = 0.0; - } else { - vertex.start_width = start; - vertex.end_width = end; - } - } - } - _ => {} - } - } -} - -fn width_or_default(value: f64, default: f64) -> f64 { - if value.abs() <= 1e-12 { - default - } else { - value - } -} - -fn nearly_equal(a: f64, b: f64) -> bool { - (a - b).abs() <= 1e-9 -} - -fn widths_are_same(widths: &[(f64, f64)]) -> bool { - let Some(first) = widths.first() else { - return true; - }; - widths - .iter() - .all(|value| nearly_equal(value.0, first.0) && nearly_equal(value.1, first.1)) -} - -fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { - if count == 0 || source.is_empty() { - return Vec::new(); - } - if count == 1 || source.len() == 1 { - return vec![source[0]; count]; - } - (0..count) - .map(|index| { - let source_index = index.saturating_mul(source.len() - 1) / count.saturating_sub(1); - source[source_index] - }) - .collect() -} diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 1cfd6df3b..8af94be9c 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -37,15 +37,9 @@ impl OpenCADStudio { handles: &[acadrust::Handle], base: glam::DVec3, ) -> usize { - let (entities, deps) = { - let document = &self.tabs[i].scene.document; - let entities: Vec<_> = handles - .iter() - .filter_map(|&handle| document.get_entity(handle).cloned()) - .collect(); - let deps = super::super::ClipboardDeps::capture(document, &entities); - (entities, deps) - }; + // Clone + dep-capture live in the shared kernel; storage stays here. + let (entities, deps) = + super::super::command_driver::copy_to_clipboard_kernel(&self.tabs[i].scene.document, handles); let count = entities.len(); self.clipboard_base = base; self.clipboard = entities; diff --git a/src/entities/match_props.rs b/src/entities/match_props.rs new file mode 100644 index 000000000..0d5e2dd60 --- /dev/null +++ b/src/entities/match_props.rs @@ -0,0 +1,688 @@ +//! MATCHPROP special-property kernels (#281). +//! +//! Free functions shared by the MATCHPROP handler and headless callers +//! (buttons, MCP, plugins). No `OpenCADStudio` state required. + +/// MATCHPROP special properties (#281): copy every STYLE-affecting field from +/// `src` to `dst` when the destination supports it — never content (text +/// strings, block names) or placement (positions, rotations of the object +/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses +/// Dimension / Leader / Tolerance. +pub fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) { + use acadrust::EntityType as E; + + // Text-ish source formatting. + let text_fmt = match src { + E::Text(t) => Some(( + t.style.clone(), + t.height, + Some(t.width_factor), + Some(t.oblique_angle), + )), + E::MText(m) => Some((m.style.clone(), m.height, None, None)), + E::AttributeDefinition(a) => Some(( + a.text_style.clone(), + a.height, + Some(a.width_factor), + Some(a.oblique_angle), + )), + E::AttributeEntity(a) => Some(( + a.text_style.clone(), + a.height, + Some(a.width_factor), + Some(a.oblique_angle), + )), + _ => None, + }; + if let Some((style, height, wf, ob)) = text_fmt { + match dst { + E::Text(t) => { + t.style = style.clone(); + t.height = height; + if let Some(value) = wf { + t.width_factor = value; + } + if let Some(value) = ob { + t.oblique_angle = value; + } + } + E::MText(m) => { + m.style = style.clone(); + m.height = height; + } + E::AttributeDefinition(a) => { + a.text_style = style.clone(); + a.height = height; + if let Some(value) = wf { + a.width_factor = value; + } + if let Some(value) = ob { + a.oblique_angle = value; + } + } + E::AttributeEntity(a) => { + a.text_style = style; + a.height = height; + if let Some(value) = wf { + a.width_factor = value; + } + if let Some(value) = ob { + a.oblique_angle = value; + } + } + _ => {} + } + } + // MText-only extras (line spacing, background fill). + if let (E::MText(sm), E::MText(dm)) = (src, dst as &mut E) { + dm.drawing_direction = sm.drawing_direction; + dm.line_spacing_factor = sm.line_spacing_factor; + dm.line_spacing_style = sm.line_spacing_style; + dm.background_fill_flags = sm.background_fill_flags; + dm.background_scale = sm.background_scale; + dm.background_color = sm.background_color; + dm.background_transparency = sm.background_transparency; + } + + // Dimension style name crosses the three dim-styled families. + let dim_style = match src { + E::Dimension(d) => Some(d.base().style_name.clone()), + E::Leader(l) => Some(l.dimension_style.clone()), + E::Tolerance(t) => Some(t.dimension_style_name.clone()), + _ => None, + }; + if let Some(ds) = dim_style { + match dst { + E::Dimension(d) => d.base_mut().style_name = ds, + E::Leader(l) => l.dimension_style = ds, + E::Tolerance(t) => t.dimension_style_name = ds, + _ => {} + } + } + + // Hatch pattern / gradient — everything but the boundary. + if let (E::Hatch(sh), E::Hatch(dh)) = (src, dst as &mut E) { + dh.pattern = sh.pattern.clone(); + dh.pattern_type = sh.pattern_type; + dh.pattern_angle = sh.pattern_angle; + dh.pattern_scale = sh.pattern_scale; + dh.is_solid = sh.is_solid; + dh.is_double = sh.is_double; + dh.style = sh.style; + dh.gradient_color = sh.gradient_color.clone(); + } + + // Polyline display style crosses lightweight and legacy 2D polylines. + // Per-vertex/tapered widths are resampled over the destination vertices; + // they must not be flattened into the source's constant-width field. + if let Some(style) = PolylineMatchStyle::from_entity(src) { + style.apply_to(dst); + } + + if let (E::Leader(sl), E::Leader(dl)) = (src, dst as &mut E) { + dl.arrow_enabled = sl.arrow_enabled; + dl.path_type = sl.path_type; + dl.hookline_direction = sl.hookline_direction; + dl.hookline_enabled = sl.hookline_enabled; + dl.override_color = sl.override_color; + dl.dimension_gap = sl.dimension_gap; + dl.arrowhead_type = sl.arrowhead_type; + dl.arrow_size = sl.arrow_size; + dl.byblock_color = sl.byblock_color; + } + if let (E::Tolerance(st), E::Tolerance(dt)) = (src, dst as &mut E) { + dt.dimension_style_handle = st.dimension_style_handle; + dt.text_height = st.text_height; + dt.dimension_gap = st.dimension_gap; + } + + // Paper-space geometry and view position stay; viewport display/plot + // styling and effective scale follow the source. + if let (E::Viewport(sv), E::Viewport(dv)) = (src, dst as &mut E) { + let scale = crate::scene::vp_effective_scale(sv.custom_scale, sv.view_height, sv.height); + dv.custom_scale = scale; + if scale.abs() > 1e-9 { + dv.view_height = dv.height / scale; + } + dv.status.locked = sv.status.locked; + dv.status.hide_plot = sv.status.hide_plot; + dv.render_mode = sv.render_mode; + dv.style_sheet = sv.style_sheet.clone(); + dv.shade_plot_mode = sv.shade_plot_mode; + dv.background_handle = sv.background_handle; + dv.shade_plot_handle = sv.shade_plot_handle; + dv.visual_style_handle = sv.visual_style_handle; + dv.default_lighting = sv.default_lighting; + dv.default_lighting_type = sv.default_lighting_type; + dv.brightness = sv.brightness; + dv.contrast = sv.contrast; + dv.ambient_color = sv.ambient_color; + } + + if let (E::Table(st), E::Table(dt)) = (src, dst as &mut E) { + dt.table_style_handle = st.table_style_handle; + dt.base_style = st.base_style.clone(); + dt.override_flag = st.override_flag; + dt.override_border_color = st.override_border_color; + dt.override_border_line_weight = st.override_border_line_weight; + dt.override_border_visibility = st.override_border_visibility; + dt.legacy_style_override = st.legacy_style_override.clone(); + dt.legacy_border_colors = st.legacy_border_colors.clone(); + dt.legacy_border_line_weights = st.legacy_border_line_weights.clone(); + dt.legacy_border_visibility = st.legacy_border_visibility.clone(); + } + + if let (E::MLine(sm), E::MLine(dm)) = (src, dst as &mut E) { + dm.style_handle = sm.style_handle; + dm.style_name = sm.style_name.clone(); + dm.style_element_count = sm.style_element_count; + dm.justification = sm.justification; + dm.scale_factor = sm.scale_factor; + // Stored segment offsets bake the old style into each vertex. Empty + // data deliberately selects the renderer's style-derived fallback. + for vertex in &mut dm.vertices { + vertex.segments.clear(); + } + } + + // MultiLeader style + every style-affecting override; content and + // geometry (leader points, text, block handle) stay. + if let (E::MultiLeader(sm), E::MultiLeader(dm)) = (src, dst as &mut E) { + dm.style_handle = sm.style_handle; + dm.path_type = sm.path_type; + dm.line_color = sm.line_color; + dm.line_type_handle = sm.line_type_handle; + dm.line_weight = sm.line_weight; + dm.enable_landing = sm.enable_landing; + dm.enable_dogleg = sm.enable_dogleg; + dm.dogleg_length = sm.dogleg_length; + dm.arrowhead_handle = sm.arrowhead_handle; + dm.arrowhead_size = sm.arrowhead_size; + dm.text_style_handle = sm.text_style_handle; + dm.text_color = sm.text_color; + dm.text_frame = sm.text_frame; + dm.text_height = sm.text_height; + dm.text_left_attachment = sm.text_left_attachment; + dm.text_right_attachment = sm.text_right_attachment; + dm.text_top_attachment = sm.text_top_attachment; + dm.text_bottom_attachment = sm.text_bottom_attachment; + dm.text_attachment_direction = sm.text_attachment_direction; + dm.text_attachment_point = sm.text_attachment_point; + dm.text_alignment = sm.text_alignment; + dm.text_angle_type = sm.text_angle_type; + dm.text_direction_negative = sm.text_direction_negative; + dm.text_align_in_ipe = sm.text_align_in_ipe; + dm.block_content_color = sm.block_content_color; + dm.block_connection_type = sm.block_connection_type; + dm.block_scale = sm.block_scale; + dm.scale_factor = sm.scale_factor; + dm.property_override_flags = sm.property_override_flags; + dm.enable_annotation_scale = sm.enable_annotation_scale; + dm.extend_leader_to_text = sm.extend_leader_to_text; + dm.arrowhead_overrides = sm.arrowhead_overrides.clone(); + + let sc = &sm.context; + let dc = &mut dm.context; + dc.scale_factor = sc.scale_factor; + dc.text_height = sc.text_height; + dc.text_width = sc.text_width; + dc.text_boundary_height = sc.text_boundary_height; + dc.line_spacing_factor = sc.line_spacing_factor; + dc.line_spacing_style = sc.line_spacing_style; + dc.text_color = sc.text_color; + dc.text_attachment_point = sc.text_attachment_point; + dc.text_flow_direction = sc.text_flow_direction; + dc.text_alignment = sc.text_alignment; + dc.text_left_attachment = sc.text_left_attachment; + dc.text_right_attachment = sc.text_right_attachment; + dc.text_top_attachment = sc.text_top_attachment; + dc.text_bottom_attachment = sc.text_bottom_attachment; + dc.text_height_automatic = sc.text_height_automatic; + dc.word_break = sc.word_break; + dc.text_style_handle = sc.text_style_handle; + dc.block_content_scale = sc.block_content_scale; + dc.block_content_color = sc.block_content_color; + dc.block_connection_type = sc.block_connection_type; + dc.column_type = sc.column_type; + dc.column_width = sc.column_width; + dc.column_gutter = sc.column_gutter; + dc.column_flow_reversed = sc.column_flow_reversed; + dc.column_sizes = sc.column_sizes.clone(); + dc.background_fill_enabled = sc.background_fill_enabled; + dc.background_mask_fill_on = sc.background_mask_fill_on; + dc.background_fill_color = sc.background_fill_color; + dc.background_scale_factor = sc.background_scale_factor; + dc.background_transparency = sc.background_transparency; + dc.arrowhead_size = sc.arrowhead_size; + dc.landing_gap = sc.landing_gap; + dc.scale_handle = sc.scale_handle; + } + + // External-reference identity, placement and clip geometry stay. Only + // display controls/appearance are matched. + if let (E::RasterImage(si), E::RasterImage(di)) = (src, dst as &mut E) { + di.flags = si.flags; + di.clipping_enabled = si.clipping_enabled; + di.brightness = si.brightness; + di.contrast = si.contrast; + di.fade = si.fade; + di.clip_boundary.clip_mode = si.clip_boundary.clip_mode; + } + if let (E::Wipeout(sw), E::Wipeout(dw)) = (src, dst as &mut E) { + dw.flags = sw.flags; + dw.clipping_enabled = sw.clipping_enabled; + dw.brightness = sw.brightness; + dw.contrast = sw.contrast; + dw.fade = sw.fade; + dw.clip_mode = sw.clip_mode; + } + if let (E::Underlay(su), E::Underlay(du)) = (src, dst as &mut E) { + du.flags = su.flags; + du.contrast = su.contrast; + du.fade = su.fade; + du.clip_inverted = su.clip_inverted; + } +} + +pub struct PolylineMatchStyle { + pub plinegen: bool, + pub widths: Vec<(f64, f64)>, +} + +impl PolylineMatchStyle { + pub fn from_entity(entity: &acadrust::EntityType) -> Option { + use acadrust::EntityType as E; + + match entity { + E::LwPolyline(poly) => { + let widths = if poly.vertices.is_empty() { + vec![(poly.constant_width, poly.constant_width)] + } else { + poly.vertices + .iter() + .map(|v| { + ( + width_or_default(v.start_width, poly.constant_width), + width_or_default(v.end_width, poly.constant_width), + ) + }) + .collect() + }; + Some(Self { + plinegen: poly.plinegen, + widths, + }) + } + E::Polyline2D(poly) => { + let widths = if poly.vertices.is_empty() { + vec![(poly.start_width, poly.end_width)] + } else { + poly.vertices + .iter() + .map(|v| { + ( + width_or_default(v.start_width, poly.start_width), + width_or_default(v.end_width, poly.end_width), + ) + }) + .collect() + }; + Some(Self { + plinegen: poly.flags.bits() + & acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits() + != 0, + widths, + }) + } + _ => None, + } + } + + pub fn apply_to(&self, entity: &mut acadrust::EntityType) { + use acadrust::EntityType as E; + + match entity { + E::LwPolyline(poly) => { + poly.plinegen = self.plinegen; + let sampled = resample_widths(&self.widths, poly.vertices.len()); + let first = sampled.first().copied().unwrap_or((0.0, 0.0)); + let can_use_constant = widths_are_same(&sampled) && nearly_equal(first.0, first.1); + poly.constant_width = if can_use_constant { first.0 } else { 0.0 }; + for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { + if can_use_constant { + vertex.start_width = 0.0; + vertex.end_width = 0.0; + } else { + vertex.start_width = start; + vertex.end_width = end; + } + } + } + E::Polyline2D(poly) => { + let mut bits = poly.flags.bits(); + let flag = acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits(); + if self.plinegen { + bits |= flag; + } else { + bits &= !flag; + } + poly.flags = acadrust::entities::PolylineFlags::from_bits(bits); + let sampled = resample_widths(&self.widths, poly.vertices.len()); + let first = sampled.first().copied().unwrap_or((0.0, 0.0)); + let can_use_defaults = widths_are_same(&sampled); + if can_use_defaults { + poly.start_width = first.0; + poly.end_width = first.1; + } else { + poly.start_width = 0.0; + poly.end_width = 0.0; + } + for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { + if can_use_defaults { + vertex.start_width = 0.0; + vertex.end_width = 0.0; + } else { + vertex.start_width = start; + vertex.end_width = end; + } + } + } + _ => {} + } + } +} + +pub fn width_or_default(value: f64, default: f64) -> f64 { + if value.abs() <= 1e-12 { + default + } else { + value + } +} + +pub fn nearly_equal(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 +} + +pub fn widths_are_same(widths: &[(f64, f64)]) -> bool { + let Some(first) = widths.first() else { + return true; + }; + widths + .iter() + .all(|value| nearly_equal(value.0, first.0) && nearly_equal(value.1, first.1)) +} + +pub fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { + if count == 0 || source.is_empty() { + return Vec::new(); + } + if count == 1 || source.len() == 1 { + return vec![source[0]; count]; + } + (0..count) + .map(|index| { + let source_index = index.saturating_mul(source.len() - 1) / count.saturating_sub(1); + source[source_index] + }) + .collect() +} + +/// Why [`match_layer_kernel`] could not run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MatchLayerError { + /// The source handle names no entity in the document. + SourceNotFound, +} + +/// LAYMATCH kernel: copy the source entity's layer onto every destination +/// entity, returning how many destinations were updated. Destination handles +/// that resolve to nothing are skipped. Lock-filtering, undo, dirty-state and +/// command-line echo stay caller-side in `handle_match_entity_layer`. +pub fn match_layer_kernel( + doc: &mut acadrust::CadDocument, + dest: &[acadrust::Handle], + src: acadrust::Handle, +) -> Result { + let layer = doc + .get_entity(src) + .map(|e| e.common().layer.clone()) + .ok_or(MatchLayerError::SourceNotFound)?; + let mut count = 0; + for handle in dest { + if let Some(e) = doc.get_entity_mut(*handle) { + e.as_entity_mut().set_layer(layer.clone()); + count += 1; + } + } + Ok(count) +} + +/// MATCHPROP per-pair transfer switches. The entity-level transfers +/// (`copy_common`, `copy_thickness`, `copy_special`) run inside +/// [`match_properties_kernel`]; the document-level transfers +/// (`copy_hatch_background`, `copy_dim_overrides`) stay caller-side in +/// `handle_match_properties` (they need `&mut CadDocument` for +/// `set_entity_xdata` / `dim_override::replace`) but are gated by the same +/// flags so headless callers and the handler agree on what "match" means. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MatchOpts { + /// Layer, color, lineweight, linetype (+handle/scale), transparency, + /// color name/book, visual styles, material, shadow, plotstyle. + pub copy_common: bool, + /// Extrusion thickness (DXF 39) where the destination carries one. + pub copy_thickness: bool, + /// Style-affecting fields via [`match_special_props`] (text formatting, + /// dim style, hatch pattern/gradient, polyline widths, …). + pub copy_special: bool, + /// Hatch background colour (`HATCHBACKGROUNDCOLOR` xdata). Caller-side. + pub copy_hatch_background: bool, + /// Dimension-style overrides (`DSTYLE` xdata payload). Caller-side. + pub copy_dim_overrides: bool, +} + +impl MatchOpts { + /// Every transfer enabled — the interactive MATCHPROP behaviour. + pub fn all() -> Self { + Self { + copy_common: true, + copy_thickness: true, + copy_special: true, + copy_hatch_background: true, + copy_dim_overrides: true, + } + } +} + +impl Default for MatchOpts { + fn default() -> Self { + Self::all() + } +} + +/// MATCHPROP kernel: transfer the source entity's properties onto one +/// destination entity, gated by `opts`. Covers the `&EntityType`-level work +/// from `handle_match_properties` — common fields, thickness and +/// [`match_special_props`]. The `&mut CadDocument` paths (hatch-background +/// xdata, dim-override replace, dim-block invalidation, fill-model refresh) +/// stay caller-side; lock-filtering, undo, dirty-state, selection and echo +/// stay caller-side too. +pub fn match_properties_kernel( + src: &acadrust::EntityType, + dst: &mut acadrust::EntityType, + opts: &MatchOpts, +) { + if opts.copy_common { + let common = src.common().clone(); + dst.as_entity_mut().set_layer(common.layer.clone()); + crate::scene::view::dispatch::apply_color(dst, common.color); + crate::scene::view::dispatch::apply_line_weight(dst, common.line_weight); + { + let dst_common = dst.common_mut(); + dst_common.linetype = common.linetype.clone(); + dst_common.linetype_handle = common.linetype_handle; + dst_common.linetype_scale = common.linetype_scale; + dst_common.transparency = common.transparency; + dst_common.color_name = common.color_name.clone(); + dst_common.color_book_handle = common.color_book_handle; + dst_common.full_visual_style_handle = common.full_visual_style_handle; + dst_common.face_visual_style_handle = common.face_visual_style_handle; + dst_common.edge_visual_style_handle = common.edge_visual_style_handle; + dst_common.material_flags = common.material_flags; + dst_common.material_handle = common.material_handle; + dst_common.shadow_flags = common.shadow_flags; + dst_common.plotstyle_flags = common.plotstyle_flags; + dst_common.plotstyle_handle = common.plotstyle_handle; + } + } + if opts.copy_thickness { + if let Some(value) = crate::scene::view::dispatch::entity_thickness(src) { + crate::scene::view::dispatch::set_entity_thickness(dst, value); + } + } + if opts.copy_special { + match_special_props(src, dst); + } + // `copy_hatch_background` / `copy_dim_overrides` are intentionally not + // applied here: both ride xdata records through `&mut CadDocument` + // (`set_entity_xdata`, `dim_override::replace`). The handler checks the + // same flags before running those paths. +} + +#[cfg(test)] +mod match_layer_kernel_tests { + use super::{MatchLayerError, match_layer_kernel}; + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + fn layer_of(doc: &acadrust::CadDocument, handle: acadrust::Handle) -> String { + doc.get_entity(handle).unwrap().common().layer.clone() + } + + #[test] + fn copies_src_layer_onto_every_dest_and_returns_count() { + let mut doc = acadrust::CadDocument::new(); + let src = doc.add_entity(line_on("WALLS")).unwrap(); + let a = doc.add_entity(line_on("0")).unwrap(); + let b = doc.add_entity(line_on("0")).unwrap(); + + let count = match_layer_kernel(&mut doc, &[a, b], src).unwrap(); + + assert_eq!(count, 2); + assert_eq!(layer_of(&doc, a), "WALLS"); + assert_eq!(layer_of(&doc, b), "WALLS"); + assert_eq!(layer_of(&doc, src), "WALLS"); + } + + #[test] + fn missing_src_errors_and_leaves_dest_untouched() { + let mut doc = acadrust::CadDocument::new(); + let dest = doc.add_entity(line_on("0")).unwrap(); + let missing = doc.allocate_handle(); + + let err = match_layer_kernel(&mut doc, &[dest], missing).unwrap_err(); + + assert_eq!(err, MatchLayerError::SourceNotFound); + assert_eq!(layer_of(&doc, dest), "0"); + } + + #[test] + fn skips_missing_dest_handles_but_counts_the_rest() { + let mut doc = acadrust::CadDocument::new(); + let src = doc.add_entity(line_on("WALLS")).unwrap(); + let dest = doc.add_entity(line_on("0")).unwrap(); + let missing = doc.allocate_handle(); + + let count = match_layer_kernel(&mut doc, &[dest, missing], src).unwrap(); + + assert_eq!(count, 1); + assert_eq!(layer_of(&doc, dest), "WALLS"); + } +} + +#[cfg(test)] +mod match_properties_kernel_tests { + use super::{MatchOpts, match_properties_kernel}; + use acadrust::EntityType; + + fn line_src() -> EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = "WALLS".to_string(); + line.common.linetype_scale = 2.5; + line.thickness = 1.25; + EntityType::Line(line) + } + + fn line_dst() -> EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = "0".to_string(); + line.common.linetype_scale = 1.0; + line.thickness = 0.0; + EntityType::Line(line) + } + + #[test] + fn transfers_common_fields_and_thickness_when_enabled() { + let src = line_src(); + let mut dst = line_dst(); + + match_properties_kernel(&src, &mut dst, &MatchOpts::all()); + + assert_eq!(dst.common().layer, "WALLS"); + assert!((dst.common().linetype_scale - 2.5).abs() < 1e-9); + assert!( + crate::scene::view::dispatch::entity_thickness(&dst) == Some(1.25), + "thickness must follow the source, got {:?}", + crate::scene::view::dispatch::entity_thickness(&dst) + ); + } + + #[test] + fn transfers_text_style_via_special_props_flag() { + let mut src_text = acadrust::entities::Text::new(); + src_text.value = "SRC".into(); + src_text.style = "BIG".into(); + src_text.height = 5.0; + let src = EntityType::Text(src_text); + let mut dst_text = acadrust::entities::Text::new(); + dst_text.value = "DST".into(); + let mut dst = EntityType::Text(dst_text); + + match_properties_kernel(&src, &mut dst, &MatchOpts::all()); + + match &dst { + EntityType::Text(t) => { + assert_eq!(t.style, "BIG"); + assert!((t.height - 5.0).abs() < 1e-9); + // Content never crosses. + assert_eq!(t.value, "DST"); + } + other => panic!("expected Text, got {other:?}"), + } + } + + #[test] + fn respects_disabled_common_and_special_flags() { + let src = line_src(); + let mut dst = line_dst(); + let opts = MatchOpts { + copy_common: false, + copy_thickness: true, + copy_special: false, + copy_hatch_background: false, + copy_dim_overrides: false, + }; + + match_properties_kernel(&src, &mut dst, &opts); + + assert_eq!(dst.common().layer, "0", "disabled common must not copy"); + assert!( + crate::scene::view::dispatch::entity_thickness(&dst) == Some(1.25), + "enabled thickness must still copy" + ); + } +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 8d1d02182..3a956edc5 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -15,6 +15,7 @@ pub mod leader; pub mod light; pub mod line; pub mod lwpolyline; +pub mod match_props; pub mod mesh; pub mod mline; pub mod mtext;