Browse / github.com/storytold/designcraft / patch
Selection: a turned object's box and handles turn with it; its handles resize it along its own axes
A rotated object shows a second, upright selection box, and its handles skew it **What happens:** A selected object that is rotated (say 15°) shows two boxes: - its own outline, turned; - a second, upright bounding box around it, which carries the eight handles. Dragging one of those handles resizes along the page's axes, so the turned object gets skewed instead of growing along its own sides. **Expected:** The selection box turns with the object: one box on its own outline, with the handles on the turned corners and side midpoints. A handle drag resizes the object along its own axes. A text frame's text keeps its size, and Cmd still scales the content too. **Steps:**
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- PiMinusMax (imported from pull request #1889, open)
- Base
ed8aabd1dc5a88652a076631c2d46886ca60c031- Size
- +165 −15 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2d15f032e8bc4c75888aaa40
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/object.rs crates/tools/src/select.rs crates/tools/src/tests.rs crates/ui-egui/src/canvas.rs
@@ -12,6 +12,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-26** · A turned object's selection turns with it: one box on the object's own+ outline with its handles on the turned corners, instead of a second, upright box around it; a+ handle drag resizes it along its own axes (`transform.resize` with `own`), and the rotate zone+ sits outside the turned corners. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that starts on a floating panel over the bar moves the panel, not the window, and in full screen the bar no longer zooms or moves the window. No readiness percentage change.@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> { "Resize", [], None,- "{from: rect, to: rect, content?: bool (scale content), distribute?: bool (Live Distribute: objects keep their size, their centres spread with the bounds), ids?}",+ "{from: rect, to: rect, content?: bool (scale content), distribute?: bool (Live Distribute: objects keep their size, their centres spread with the bounds), own?: bool (from and to are in each object's own space, before its rotation: a turned object resizes along its own axes), ids?}", has_selection, transform_resize ),@@ -1229,7 +1229,8 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> { let sy = ratio(to.height(), from.height()); let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx }; let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };- let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));+ let own = bool_or(p, "own", false);+ let rect_map = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)); let strokes = s.prefs.scale_strokes; s.edit(|d, _| { for id in &ids {@@ -1237,6 +1238,8 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> { if it.locked { continue; }+ // In its own space the map applies before the object's transform.+ let m = if own && it.xf.determinant().abs() > 1e-12 { it.xf * rect_map * it.xf.inverse() } else { rect_map }; if distribute { let c = it.bounds().center(); it.xf = Affine::translate(m * c - c) * it.xf;@@ -4075,6 +4078,27 @@ mod reference_point_tests { mod again_tests { use super::*; + /// `own`: a turned frame resizes along its own axes: its path grows, its rotation stays.+ #[test]+ fn a_turned_frame_resizes_along_its_own_axes() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap()["id"].as_u64().unwrap());+ s.execute("transform.rotate", &json!({"angle": 30})).unwrap();+ let before = s.doc().unwrap().doc.item(id).unwrap().clone();+ let own = before.inner_bounds();+ let wider = designcraft_geom::Rect::new(own.x0, own.y0, own.x0 + own.width() * 2.0, own.y1);+ let r = |r: designcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);+ s.execute("transform.resize", &json!({"from": r(own), "to": r(wider), "own": true})).unwrap();+ let it = s.doc().unwrap().doc.item(id).unwrap().clone();+ assert_eq!(it.xf, before.xf, "the rotation (and position) stay");+ let b = it.inner_bounds();+ assert!((b.width() - own.width() * 2.0).abs() < 1e-6 && (b.height() - own.height()).abs() < 1e-6, "{b:?}");+ // The fixed side stays where it was on the spread.+ let p = designcraft_geom::Point::new;+ assert!(((it.xf * p(b.x0, b.y0)) - (before.xf * p(own.x0, own.y0))).hypot() < 1e-6);+ }+ #[test] fn transform_again_sequence_and_clear() { let mut s = Session::new();@@ -48,6 +48,15 @@ enum Drag { from: Rect, spread: SpreadRef, },+ /// Resizing one turned object along its own axes (see [`turned_box`]): `from` is its box in+ /// its own space, `to_inner` maps spread points there.+ ResizeTurned {+ id: ItemId,+ handle: usize,+ from: Rect,+ to_inner: Affine,+ spread: SpreadRef,+ }, /// Resizing a selected placed graphic by its own bounding-box handles (`from`: its bounds on /// the spread at pointer down). ContentResize {@@ -426,21 +435,57 @@ fn content_handle_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize)> { handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol).map(|h| (id, h)) } +/// The box of `it` in its own space when `m` (its own space to the canvas) turns it off the+/// canvas axes (rotated other than by quarter turns, or skewed), so its bounding box on the canvas+/// is not its own box. Groups keep the canvas box.+pub fn turned_box(it: &Item, m: Affine) -> Option<Rect> {+ let [a, b, c, d, _, _] = m.as_coeffs();+ let scale = (a.abs() + b.abs() + c.abs() + d.abs()).max(1e-12);+ // Both the diagonal and the off-diagonal take part: neither upright nor a quarter turn.+ let off_axes = (a.abs() + d.abs()).min(b.abs() + c.abs()) / scale > 1e-9;+ if it.is_group() || !off_axes || m.determinant().abs() < 1e-12 {+ return None;+ }+ let r = it.inner_bounds();+ (r.width() > 0.0 && r.height() > 0.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())).then_some(r)+}++/// The one selected object when it is turned: its id, its box in its own space and the map from+/// there to the canvas.+fn selected_turned(cx: &ToolContext) -> Option<(ItemId, Rect, Affine)> {+ let [id] = cx.selection.items.as_slice() else { return None };+ let it = cx.doc.item(*id)?;+ let m = cx.item_canvas_xf(*id)? * it.xf;+ Some((*id, turned_box(it, m)?, m))+}++/// The selection's handles on the canvas: on a turned object's own box, else on the bounding box.+fn selection_handles(cx: &ToolContext) -> Option<[Point; 8]> {+ if let Some((_, r, m)) = selected_turned(cx) {+ return Some(handles(r).map(|h| m * h));+ }+ Some(handles(cx.selection_bounds()?))+}+ fn handle_at(cx: &ToolContext, p: Point) -> Option<usize> { // With a placed graphic selected, the handles are the graphic's, not the frame's. if selected_graphic(cx).is_some() { return None; }- let b = cx.selection_bounds()?; let tol = cx.tol(5.0);- handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol)+ selection_handles(cx)?.iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol) } -/// Side handles are drawn on the canvas bounding box; fitting uses the frame's local axes.+/// Side handles are drawn on the canvas bounding box (a turned object's on its own box); fitting+/// uses the frame's local axes. fn fitting_axis(cx: &ToolContext, handle: usize) -> &'static str { if handle.is_multiple_of(2) { return "both"; }+ // A turned object's handles sit on its own box: top and bottom set its height.+ if selected_turned(cx).is_some() {+ return if matches!(handle, 1 | 5) { "height" } else { "width" };+ } let canvas_height = matches!(handle, 1 | 5); let local_width_is_vertical = cx .selection@@ -458,9 +503,19 @@ fn fitting_axis(cx: &ToolContext, handle: usize) -> &'static str { /// Just outside a corner handle of the selection → rotate. fn rotate_zone(cx: &ToolContext, p: Point) -> Option<Point> {- let b = cx.selection_bounds()?; let inner = cx.tol(5.0); let outer = cx.tol(18.0);+ // A turned object: just outside a corner of its own box.+ if let Some((_, r, m)) = selected_turned(cx) {+ let local = m.inverse() * p;+ let outside = local.x < r.x0 || local.x > r.x1 || local.y < r.y0 || local.y > r.y1;+ let near = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].iter().any(|c| {+ let d = (m * *c - p).hypot();+ d > inner && d <= outer+ });+ return (outside && near).then(|| m * r.center());+ }+ let b = cx.selection_bounds()?; for c in [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] { let d = (c - p).hypot(); let outside = p.x < b.x0 || p.x > b.x1 || p.y < b.y0 || p.y > b.y1;@@ -785,6 +840,15 @@ impl Tool for SelectionTool { self.drag = Drag::Rotate { center, start_angle: (p - center).atan2(), start_rotation: crate::snap::reference_rotation(cx) }; return vec![Action::Begin("Rotate".into())]; }+ // A turned object resizes along its own axes.+ if let Some(h) = handle_at(cx, p).filter(|_| !self.direct)+ && let Some((id, from, _)) = selected_turned(cx)+ && let (Some(loc), Some(it)) = (cx.doc.find(id), cx.doc.item(id))+ {+ let to_inner = (cx.doc.parent_xf(&loc) * it.xf).inverse();+ self.drag = Drag::ResizeTurned { id, handle: h, from, to_inner, spread: loc.spread };+ return vec![Action::Begin("Resize".into())];+ } if let Some(h) = handle_at(cx, p).filter(|_| !self.direct) && let (Some(b), Some(first)) = (cx.selection_bounds(), cx.selection.items.first()) && let Some(loc) = cx.doc.find(*first)@@ -1029,6 +1093,14 @@ impl Tool for SelectionTool { json!({"from": rect_json(from), "to": rect_json(to), "content": ev.mods.cmd, "distribute": distribute}), )] }+ Drag::ResizeTurned { id, handle, from, to_inner, spread } => {+ let to = resize_rect(from, handle, to_inner * cx.layout.to_spread(spread, p), ev.mods);+ self.guides.clear();+ vec![Action::Preview(+ "transform.resize".into(),+ json!({"ids": [id.0], "from": rect_json(from), "to": rect_json(to), "own": true, "content": ev.mods.cmd}),+ )]+ } Drag::ContentResize { id, handle, from, spread } => { let to = resize_rect(from, handle, cx.layout.to_spread(spread, p), ev.mods); vec![Action::Preview("transform.set".into(), json!({"ids": [id.0], "content": true, "bounds": rect_json(to)}))]@@ -1138,6 +1210,7 @@ impl Tool for SelectionTool { match d { Drag::Move { .. } | Drag::Resize { .. }+ | Drag::ResizeTurned { .. } | Drag::ContentResize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. }@@ -1229,7 +1302,7 @@ impl Tool for SelectionTool { v.extend(self.guides.iter().cloned()); v }- Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),+ Drag::Move { .. } | Drag::Resize { .. } | Drag::ResizeTurned { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), // The whole graphic shows while it's resized, outside the frame too. Drag::ContentResize { id, .. } => vec![Overlay::ContentGhost(*id)], Drag::None | Drag::Bracket { .. } => brackets(cx),@@ -1273,7 +1346,7 @@ impl Tool for SelectionTool { } match self.drag { Drag::Move { .. } => return Cursor::Move,- Drag::Resize { handle, .. } => return handle_cursor(handle),+ Drag::Resize { handle, .. } | Drag::ResizeTurned { handle, .. } => return handle_cursor(handle), Drag::Rotate { .. } => return Cursor::Rotate, Drag::Corner { .. } => return Cursor::Arrow, _ => {}@@ -1966,3 +1966,41 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); }++/// A turned object's handles sit on its own (turned) box, not on the bounding box around it; a+/// drag on one resizes it along its own axes (`transform.resize` with `own`).+#[test]+fn a_turned_objects_handles_turn_with_it() {+ let mut d = Document::new(&NewDocument::default());+ let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 150.0));+ let turn = designcraft_geom::Affine::translate((150.0, 125.0))+ * designcraft_geom::Affine::rotate(30f64.to_radians())+ * designcraft_geom::Affine::translate((-150.0, -125.0));+ d.item_mut(id).unwrap().xf = turn;+ let s = Selection { items: vec![id], ..Default::default() };+ let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));+ let cx = ctx(&d, &s, &c, &l);+ let m = l.xf(SpreadRef::Doc(0)) * turn;+ // The corner of the bounding box around the turned rect is no handle.+ let bbox = m.transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 150.0));+ let mut t = create("selection");+ let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, bbox.x1, bbox.y1));+ assert_ne!(down, vec![Action::Begin("Resize".into())]);+ // The turned bottom right corner is.+ let mut t = create("selection");+ let br = m * Point::new(200.0, 150.0);+ assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, br.x, br.y)), vec![Action::Begin("Resize".into())]);+ // Dragged 50 pt further along its own x axis: 50 pt wider, the same height.+ let to = m * Point::new(250.0, 150.0);+ match &t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y))[0] {+ Action::Preview(cmd, p) => {+ assert_eq!(cmd, "transform.resize");+ assert_eq!(p["own"], true);+ assert_eq!(p["ids"], serde_json::json!([id.0]));+ let r: Vec<f64> = p["to"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();+ let want = [100.0, 100.0, 250.0, 150.0];+ assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{r:?}");+ }+ other => panic!("{other:?}"),+ }+}@@ -1407,13 +1407,22 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } let mut union: Option<Rect> = None; let mut color = Color32::from_rgb(79, 153, 255);+ // One turned object: its box and handles turn with it (screen corners of its own box).+ let mut turned: Option<[Pos2; 8]> = None; for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; color = layer_color(doc, it); for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, color))); }- if sel.items.len() == 1 && sel.text.is_none() {+ let own = (sel.items.len() == 1 && !sel.content).then(|| designcraft_tools::select::turned_box(it, a * it.xf)).flatten();+ if let Some(r) = own {+ let hs = designcraft_tools::select::handles(r).map(|h| xf.to_screen(a * it.xf * h));+ if sel.text.is_none() {+ painter.add(egui::Shape::closed_line(vec![hs[0], hs[2], hs[4], hs[6]], Stroke::new(1.0, color)));+ }+ turned = Some(hs);+ } else if sel.items.len() == 1 && sel.text.is_none() { // InDesign draws a 1 pt bounding box around the selection. painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } else if sel.key == Some(*id) {@@ -1477,15 +1486,17 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } let hs = 6.5; // Centre point. The Selection tool draws the live-corner widget as an overlay.- painter.rect_filled(Rect::from_center_size(u.center(), vec2(3.5, 3.5)), 0.0, color);+ let centre = turned.map_or(u.center(), |h| pos2((h[0].x + h[4].x) / 2.0, (h[0].y + h[4].y) / 2.0));+ painter.rect_filled(Rect::from_center_size(centre, vec2(3.5, 3.5)), 0.0, color); // A selected placed graphic has its own handles instead of the frame's. let graphic_selected = sel.content && sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_)));- for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64))- .into_iter()- .filter(|_| !graphic_selected)- {- let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(hs, hs));+ let box_handles = turned.unwrap_or_else(|| {+ designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64))+ .map(|h| pos2(h.x as f32, h.y as f32))+ });+ for h in box_handles.into_iter().filter(|_| !graphic_selected) {+ let r = Rect::from_center_size(h, vec2(hs, hs)); painter.rect_filled(r, 0.0, Color32::WHITE); painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside); }