[MM4] A group's box follows its children after a command moves one

shape.move or shape.setBounds on a group's child left the group's box as it
was, so inspect_slide reported stale bounds. After any edit that changes a
child's box or the child list, the group's box is refitted to the union of its
children (its child space too, so every child keeps its place on the slide).
A group that was itself moved or resized, and SmartArt groups (regenerated
inside their own box), are left alone.

PowerPoint: a group's bounding box always hugs its children; dragging one
child out grows the group, dragging it back shrinks it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Zach Berry
2026-10-27 20:38:41 -05:00
co-authored by Claude Sonnet 5.5
parent 55e5ee56b4
commit 2cad063d1d
3 changed files with 116 additions and 0 deletions
+92
View File
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// After an edit: refit every group whose children's geometry changed to the union of its
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
/// shrinks the group). The children keep their place on the slide: the group's box and its child
/// space change together. `old` is the presentation before the edit; only slides the edit
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
/// that edit.
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
// insertion or deletion shifts the indices, and shape ids repeat across slides.
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
for slide in new.slides.iter_mut() {
let Some(before) = old_slides.get(&slide.id) else { continue };
if std::sync::Arc::ptr_eq(before, slide) {
continue;
}
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
refit_list(Some(&before.shapes), shapes, 0);
}
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
for master in new.masters.iter_mut() {
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
&& !std::sync::Arc::ptr_eq(before, master)
{
refit_master(before, std::sync::Arc::make_mut(master));
}
}
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
if let (Some(before), Some(after)) = (before, after)
&& !std::sync::Arc::ptr_eq(before, after)
{
refit_master(before, std::sync::Arc::make_mut(after));
}
}
}
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
refit_list(Some(&old.shapes), &mut new.shapes, 0);
for layout in new.layouts.iter_mut() {
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
}
}
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
// is bounded like the model's other walks.
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
return;
}
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
for sh in list.iter_mut() {
let before = by_id.get(&sh.id).copied();
let old_children = before.and_then(|b| match &b.kind {
ShapeKind::Group { children, .. } => Some(children),
_ => None,
});
let own_before = before.map(|b| {
(
b.xfrm,
match &b.kind {
ShapeKind::Group { child, .. } => Some(*child),
_ => None,
},
)
});
let smart = sh.diagram.is_some();
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
refit_list(old_children, children, depth + 1);
if smart {
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
continue;
}
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
continue;
}
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
if same {
continue;
}
let Some(group) = sh.xfrm else { continue };
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
let Some(u) = union_bounds(&xs) else { continue };
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
continue;
}
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
let placed = child_to_parent(&group, child, &union);
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
*child = union;
}
}
/// A child's xfrm in slide space, given its group.
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };