Skip to main content

dotloom_document/
clipboard.rs

1//! Copy/paste with reference remapping.
2//!
3//! Policy (documented in `docs/file-format.md`):
4//!
5//! * Copied entities get new IDs; references between copied entities (properties,
6//!   constraints, groups) are remapped to the new IDs.
7//! * Constraints are copied only when *all* their entities are copied.
8//! * A property reference to an entity outside the copied set is kept when that
9//!   entity exists in the destination document (e.g. a pasted door keeps its host
10//!   wall) and dropped otherwise; dropped references are reported.
11//! * Groups are copied when all their members are copied.
12//! * The paste offset is applied to entity transforms and to `fixPoint` targets.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use dotloom_geometry::{Affine, Vector};
17use serde::{Deserialize, Serialize};
18
19use crate::{Constraint, ConstraintId, Document, Entity, EntityId, Group, GroupId, LayerId, PropValue, RuleSpec};
20
21/// Copied content, independent of the source document.
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23pub struct Clipboard {
24    /// Entities in draw order.
25    pub entities: Vec<Entity>,
26    /// Constraints among the copied entities.
27    pub constraints: Vec<Constraint>,
28    /// Groups whose members were all copied.
29    pub groups: Vec<Group>,
30}
31
32/// Result of a paste.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct PasteReport {
35    /// Old → new entity IDs.
36    pub entities: BTreeMap<EntityId, EntityId>,
37    /// New constraint IDs.
38    pub constraints: Vec<ConstraintId>,
39    /// New group IDs.
40    pub groups: Vec<GroupId>,
41    /// `(new entity, property)` references dropped because the target is missing.
42    pub dropped_refs: Vec<(EntityId, String)>,
43}
44
45impl Document {
46    /// Copy entities (and the constraints/groups fully inside the selection).
47    #[must_use]
48    pub fn copy(&self, ids: &[EntityId]) -> Clipboard {
49        let set: BTreeSet<EntityId> = ids.iter().copied().filter(|id| self.entity(*id).is_some()).collect();
50        let entities: Vec<Entity> = self.entities().filter(|e| set.contains(&e.id)).cloned().collect();
51        let constraints = self
52            .constraints()
53            .filter(|c| {
54                let ents = c.rule.entities();
55                !ents.is_empty() && ents.iter().all(|e| set.contains(e)) && c.owner.is_none_or(|o| set.contains(&o))
56            })
57            .cloned()
58            .collect();
59        let groups = self
60            .groups()
61            .filter(|g| !g.members.is_empty() && g.members.iter().all(|m| set.contains(m)))
62            .cloned()
63            .collect();
64        Clipboard { entities, constraints, groups }
65    }
66
67    /// Paste clipboard content with new IDs, translated by `offset`. Entities whose
68    /// layer does not exist here are placed on `fallback_layer`.
69    pub fn paste(&mut self, clip: &Clipboard, offset: Vector, fallback_layer: LayerId) -> PasteReport {
70        let mut report = PasteReport::default();
71        for e in &clip.entities {
72            let new = EntityId(self.alloc_id());
73            report.entities.insert(e.id, new);
74        }
75        let t = Affine::translate(offset);
76        for e in &clip.entities {
77            let Some(&new_id) = report.entities.get(&e.id) else { continue };
78            let mut n = e.clone();
79            n.id = new_id;
80            if self.layer(n.layer).is_none() {
81                n.layer = fallback_layer;
82            }
83            n.transform = n.transform.then(t);
84            let mut dropped = Vec::new();
85            for (k, v) in &mut n.props {
86                match v {
87                    PropValue::Ref(r) => {
88                        if let Some(m) = report.entities.get(&r.entity) {
89                            r.entity = *m;
90                        } else if self.entity(r.entity).is_none() {
91                            dropped.push(k.clone());
92                        }
93                    }
94                    PropValue::Anchor(a) => {
95                        if let Some(m) = report.entities.get(&a.entity) {
96                            a.entity = *m;
97                        } else if self.entity(a.entity).is_none() {
98                            dropped.push(k.clone());
99                        }
100                    }
101                    _ => {}
102                }
103            }
104            for k in dropped {
105                n.props.remove(&k);
106                report.dropped_refs.push((new_id, k));
107            }
108            self.insert_entity(n);
109        }
110        for c in &clip.constraints {
111            let mut n = c.clone();
112            if !n.rule.remap(&report.entities) {
113                continue;
114            }
115            if let Some(o) = n.owner {
116                n.owner = report.entities.get(&o).copied();
117            }
118            if let RuleSpec::FixPoint { at, .. } = &mut n.rule {
119                *at += offset;
120            }
121            n.id = ConstraintId(self.alloc_id());
122            report.constraints.push(n.id);
123            self.upsert_constraint(n);
124        }
125        for g in &clip.groups {
126            let mut n = g.clone();
127            n.id = GroupId(self.alloc_id());
128            n.members = g.members.iter().filter_map(|m| report.entities.get(m).copied()).collect();
129            n.children.clear();
130            report.groups.push(n.id);
131            self.upsert_group(n);
132        }
133        report
134    }
135}