Groups keep rotated members' orientation; group resize; ink erase after rotation (#441, #442, #443, #445) (#474)

- Group keeps each member's own turn (and composes a merged group's turn
  into its members); the group box covers the members' turned bounds and
  the new group starts unturned (#441).
- Size (picture.size) resizes a selected group, scaling its members
  through the group's child space like Move or Resize (#442).
- Ungroup decomposes a turned group: each member's centre is turned about
  the group's centre and its turn becomes its own within the group's (#443).
- The eraser tests the point turned back into the stroke's unturned frame,
  so turned ink is erased where it is drawn (#445).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 01:51:00 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5e84270e60
commit aa7b41d8ef
3 changed files with 213 additions and 35 deletions
+37 -6
View File
@@ -153,16 +153,18 @@ fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult {
state(s)
}
/// Ink strokes laid out on `page`: their position and geometry on the page.
fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, Arc<Freeform>, f32)> {
/// Ink strokes laid out on `page`: their position, unturned frame, turn and geometry on the page.
fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, wordcraft_geom::Spin, Arc<Freeform>, f32)> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, story, path, off, .. } => {
Placed::Object { rect, spin, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => Some((pos, *rect, f.clone(), *stroke_width)),
Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => {
Some((pos, *rect, *spin, f.clone(), *stroke_width))
}
_ => None,
}
}
@@ -177,9 +179,12 @@ pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option<Pos> {
return None;
}
let layout = s.layout();
ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, f, sw)| {
ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, spin, f, sw)| {
let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP;
f.distance(r.x, r.y, r.w, r.h, x, y).filter(|d| *d <= reach).map(|_| pos)
// A turned stroke is drawn turned about its frame's centre: test the point turned back
// into the stroke's own frame (a turn keeps distances, so the reach holds).
let (u, v) = spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y);
f.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| pos)
})
}
@@ -373,6 +378,32 @@ mod tests {
assert_eq!(s.view.draw.mode, super::DrawMode::Select);
}
/// #445: a turned stroke is erased where it is drawn, not where it was before the turn.
#[test]
fn a_rotated_stroke_is_erased_where_it_is_drawn() {
let rotated = || {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2, "color": "000000"})).unwrap();
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("arrange.rotation", &json!({"degrees": 90})).unwrap();
s
};
// Its old horizontal path is blank now: nothing to erase there, and it stays.
let mut s = rotated();
assert!(s.run("draw.erase", &json!({"page": 0, "x": 110, "y": 100})).is_err());
assert_eq!(ink(&s).len(), 1);
// The visible vertical stroke (centred on x = 150) is erased.
s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).unwrap();
assert!(ink(&s).is_empty());
let mut s = rotated();
s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 140})).unwrap();
assert!(ink(&s).is_empty(), "the point-taking eraser too");
// Hidden ink stays out of reach.
let mut s = rotated();
s.run("review.hideInk", &json!({"value": true})).unwrap();
assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err());
}
/// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which
/// changes the document: that must be one undo step and mark the document changed.
#[test]
+175 -29
View File
@@ -699,13 +699,8 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
with_obj(s, |o| match o {
InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => {
*ow = w;
*oh = h;
}
_ => {}
})
// Any drawing: a group's members scale with its frame (its child space is kept).
with_obj(s, |o| o.set_size(w, h))
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
@@ -955,29 +950,42 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
if found.iter().any(|(_, h)| h.page != page) {
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (_, h) in &found {
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
let mut children = Vec::new();
let member = |obj: &InlineObject, r: [f32; 4]| {
let mut obj = obj.clone();
obj.set_size(r[2], r[3]);
if let Some(f) = obj.float_mut() {
*f = Float::default();
}
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
};
// The members as they show on the page: each one's unturned frame and turn (a group among
// them gives its members, its turn taken into theirs).
let mut shown: Vec<([f32; 4], Spin, &InlineObject)> = Vec::new();
for (obj, hit) in &found {
let r = hit.rect;
match obj {
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
InlineObject::Group { .. } => shown.extend(shown_members(obj, [r.x, r.y, r.w, r.h])),
_ => shown.push(([r.x, r.y, r.w, r.h], hit.spin, obj)),
}
}
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
// The group's box takes in what they cover, turned.
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for ([x, y, w, h], spin, _) in &shown {
let b = spin.bounds(wordcraft_geom::Rect::new(*x, *y, *w, *h));
(x0, y0) = (x0.min(b.x), y0.min(b.y));
(x1, y1) = (x1.max(b.right()), y1.max(b.bottom()));
}
if shown.is_empty() {
return Err(CmdError::Failed("nothing to group".into()));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
// Each member keeps its turn: the group itself starts unturned.
let children: Vec<GroupChild> = shown
.into_iter()
.map(|([x, y, cw, ch], spin, obj)| {
let mut obj = obj.clone();
obj.set_size(cw, ch);
if let Some(f) = obj.float_mut() {
*f = Float::default();
f.set_spin(spin);
}
GroupChild { x: x - x0, y: y - y0, obj }
})
.collect();
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
float.set_spin(Spin::default());
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
// Take the members out, last first so the earlier positions hold, and put the group where
// the first one was.
@@ -1016,14 +1024,19 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
let float = *float;
let members: Vec<InlineObject> = obj
.group_rects(0.0, 0.0, w, h)
// Each member where the group shows it: the group's turn moves its centre about the group's
// centre and adds to its own turn.
let members: Vec<InlineObject> = shown_members(&obj, [0.0, 0.0, w, h])
.into_iter()
.map(|([x, y, cw, ch], c)| {
.map(|([x, y, cw, ch], spin, c)| {
let mut c = c.clone();
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
let (mw, mh) = (cw.max(min_size(&c)), ch.max(min_size(&c)));
c.set_size(mw, mh);
// Grown to its smallest size about its centre, so it stays centred where it was.
let (x, y) = (x - (mw - cw) / 2.0, y - (mh - ch) / 2.0);
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
f.set_spin(spin);
}
c
})
@@ -1045,6 +1058,24 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
sel_result(s)
}
/// A group's members as it shows them in its frame `[x, y, w, h]`: each member's unturned frame
/// and the turn it is drawn with (its own inside the group's). The group's turn, about its
/// centre, moves each member's centre. Empty for anything but a group.
fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4], Spin, &InlineObject)> {
let outer = group.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
let [x, y, w, h] = [x, y, w, h].map(wordcraft_geom::finite);
let (gx, gy) = (x + w / 2.0, y + h / 2.0);
group
.group_rects(x, y, w, h)
.into_iter()
.map(|([mx, my, mw, mh], c)| {
let own = c.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
let (cx, cy) = outer.apply(gx, gy, mx + mw / 2.0, my + mh / 2.0);
([cx - mw / 2.0, cy - mh / 2.0, mw, mh], own.within(outer), c)
})
.collect()
}
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
@@ -1272,6 +1303,121 @@ mod tests {
assert_eq!(all_objects(&s).len(), 2);
}
/// Page 1 as exported (`file.exportPng` at scale 1), its pixels.
fn page_pixels(s: &Session) -> Vec<u8> {
let l = s.export_layout();
wordcraft_render::render_page(&s.doc, &l.pages[0], 1.0, &Default::default()).pixels
}
/// How many pixels of two renders clearly differ (not just in antialiasing).
fn pixels_differing(a: &[u8], b: &[u8]) -> usize {
assert_eq!(a.len(), b.len());
a.chunks(4).zip(b.chunks(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 96)).count()
}
/// A rectangle at (100, 100) and an arrow at (250, 100), both 80 × 40, on a blank page; the
/// arrow turned `arrow_deg`.
fn rectangle_and_arrow(arrow_deg: f32) -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"x": 100, "y": 100})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("insert.shape", &json!({"kind": "arrow", "width": 80, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"x": 250, "y": 100})).unwrap();
s.run("arrange.rotation", &json!({"degrees": arrow_deg})).unwrap();
s
}
fn select_both(s: &mut Session) {
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("select.addObject", &json!({"index": 1})).unwrap();
}
/// #441: grouping keeps a turned member's orientation, through save and reopen too.
#[test]
fn group_keeps_a_rotated_members_orientation() {
for deg in [90.0, 0.0] {
let mut s = rectangle_and_arrow(deg);
let before = page_pixels(&s);
select_both(&mut s);
s.run("arrange.group", &json!({})).unwrap();
let (_, g) = selected(&s).unwrap();
let InlineObject::Group { children, float, .. } = &g else { panic!("a group: {g:?}") };
assert_eq!(children.len(), 2, "both members stay shapes");
assert!(children.iter().all(|c| matches!(c.obj, InlineObject::Shape { .. })));
let rots: Vec<f32> = children.iter().filter_map(|c| c.obj.frame().map(|(_, _, f)| f.rot)).collect();
assert_eq!(rots, vec![0.0, deg], "the rectangle unturned, the arrow keeps its turn");
assert_eq!(float.rot, 0.0, "the group itself is unturned");
assert!(pixels_differing(&before, &page_pixels(&s)) < 20, "{deg}°: grouping changes nothing on the page");
// Saved and reopened, it looks the same.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let reopened = Session::new(back);
assert!(pixels_differing(&before, &page_pixels(&reopened)) < 20, "{deg}°: reopened");
// Undo gives back the two objects.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 2);
assert!(pixels_differing(&before, &page_pixels(&s)) < 20);
}
}
/// #442: Size resizes a selected group, its members scaled with it, as Move or Resize does.
#[test]
fn size_resizes_a_group() {
let grouped = || {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"x": 100, "y": 100})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"x": 250, "y": 100})).unwrap();
s.run("select.addObject", &json!({"index": 0})).unwrap();
s.run("arrange.group", &json!({})).unwrap();
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap()[0]["size"], json!([230.0, 40.0]));
s
};
let mut sized = grouped();
sized.run("picture.size", &json!({"width": 400, "height": 200})).unwrap();
let list = sized.run("arrange.selectionPane", &json!({})).unwrap();
assert_eq!((list.as_array().unwrap().len(), &list[0]["kind"], &list[0]["size"]), (1, &json!("group"), &json!([400.0, 200.0])));
let mut moved = grouped();
moved.run("arrange.bounds", &json!({"width": 400, "height": 200})).unwrap();
let (_, a) = selected(&sized).unwrap();
let (_, b) = selected(&moved).unwrap();
assert_eq!(a, b, "the same group, members and all, as Move or Resize makes");
let InlineObject::Group { children, .. } = &a else { panic!() };
assert_eq!(children.len(), 2);
let members: Vec<[f32; 4]> = a.group_rects(0.0, 0.0, 400.0, 200.0).into_iter().map(|(r, _)| r).collect();
assert!(near(members[1], [400.0 * 150.0 / 230.0, 0.0, 400.0 * 80.0 / 230.0, 200.0]), "scaled with the group: {members:?}");
}
/// #443: ungrouping a turned group keeps its members where, and as turned as, it showed them.
#[test]
fn ungroup_keeps_a_rotated_groups_arrangement() {
for turn in [true, false] {
let mut s = rectangle_and_arrow(0.0);
select_both(&mut s);
s.run("arrange.group", &json!({})).unwrap();
if turn {
s.run("arrange.rotation", &json!({"degrees": 90})).unwrap();
}
let before = page_pixels(&s);
s.run("arrange.ungroup", &json!({})).unwrap();
let list = all_objects(&s);
assert_eq!(list.len(), 2, "two separate objects");
let want = if turn { 90.0 } else { 0.0 };
assert!(list.iter().all(|(_, o)| o.frame().is_some_and(|(_, _, f)| f.rot == want)));
assert!(pixels_differing(&before, &page_pixels(&s)) < 20, "turned {turn}: ungrouping changes nothing on the page");
if turn {
// The rectangle is above the arrow now, as the turned group showed it.
let r: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
assert!((r[0][0] - r[1][0]).abs() < 0.5 && r[0][1] < r[1][1], "{r:?}");
}
s.run("edit.undo", &json!({})).unwrap();
assert!(matches!(all_objects(&s).as_slice(), [(_, InlineObject::Group { .. })]));
assert_eq!(pixels_differing(&before, &page_pixels(&s)), 0);
}
}
#[test]
fn picture_alt_and_crop_round_trip() {
let mut s = Session::new(wordcraft_doc::Document::new());
+1
View File
@@ -68,6 +68,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) |
| 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) |
| 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) |
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |