From 96604acffb515d8d51cb417fba3c7b01045e91da Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 23:36:01 +0700 Subject: [PATCH] Fix #39: rotated objects keep their angle and a rotated bounding box Rotating an object baked the rotation into its geometry and kept nothing else, so the bounding box, the handles and the Transform/Properties panels fell back to the page-aligned box (the Rotate field went back to 0). Root cause: nodes carried no record of their own axes. Each node now keeps `bbox_angle` (append-only, written only when non-zero), which every transform turns with the object (mirroring transforms keep the nearer of the two possible boxes). The geometry is still baked. The selection's box (`Document::oriented_bounds`) stands at the angle the selected objects share. The Selection tool hit-tests and scales its handles in that frame, and the canvas draws it rotated. `object.setBounds` measures W/H and the reference point along it. `object.rotate {absolute}` turns the selection to an angle, which the panels' Rotate field now shows and sets. Object > Transform > Reset Bounding Box (previously a no-op) clears the angle in one undo step. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/lib.rs | 2 + crates/doc/src/node.rs | 9 + crates/doc/src/orient.rs | 195 ++++++++++++++++++++++ crates/engine/src/cmd/menucmds.rs | 23 ++- crates/engine/src/cmd/object.rs | 35 ++-- crates/engine/src/inspect.rs | 6 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_bboxrotate.rs | 211 ++++++++++++++++++++++++ crates/tools/src/select.rs | 105 +++++++++--- crates/tools/src/xform/transform.rs | 6 +- crates/ui-egui/src/canvas.rs | 15 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/lib.rs | 20 +++ crates/ui-egui/src/panels/properties.rs | 15 +- crates/ui-egui/src/panels/transform.rs | 35 +++- docs/mcp.md | 14 ++ 17 files changed, 641 insertions(+), 65 deletions(-) create mode 100644 crates/doc/src/orient.rs create mode 100644 crates/engine/src/tests_bboxrotate.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7efd344a..e6a8fc28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,7 +137,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | # | Milestone | Status | Est. remaining, one agent (h) | |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | +| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | @@ -169,7 +169,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| -| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 | +| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 857f2c7c..598956aa 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ pub mod live; pub mod marks; pub mod metadata; pub mod node; +pub mod orient; pub mod overprint; pub mod pattern; mod pixels; @@ -61,6 +62,7 @@ pub use node::Knockout; pub use node::Scaling; pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; +pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use profiles::ColorProfiles; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index c9125a16..552caf04 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -335,6 +335,11 @@ pub struct Node { /// these options. #[serde(default, skip_serializing_if = "Option::is_none")] pub slice: Option>, + /// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its + /// bounding box and handles stand at this angle. Transforms turn it with the object (see + /// [`crate::orient`]); Reset Bounding Box sets it back to 0. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub bbox_angle: f64, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -383,6 +388,7 @@ impl Node { graphic_style: None, attrs: None, slice: None, + bbox_angle: 0.0, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -572,6 +578,9 @@ impl Node { self.transform_scaled(a, &scaling.into()); } fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { + if !self.is_layer() { + self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); + } // Refitting an unplaced gradient only reproduces moves and uniform scales. if !keeps_gradient_fit(a) { self.pin_gradients(); diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs new file mode 100644 index 00000000..013e7649 --- /dev/null +++ b/crates/doc/src/orient.rs @@ -0,0 +1,195 @@ +//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`]) +//! through the transforms applied to it, so after a rotation its bounding box and handles stay +//! square to it: scaling by a side handle stretches it along its own width or height. Path +//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0. + +use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt}; + +use crate::{Document, Node, NodeId, NodeKind}; + +/// Angles closer than this (degrees) are the same. +const EPS_DEG: f64 = 1e-6; + +/// A bounding box square to a frame turned by `angle`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientedBox { + /// Counter-clockwise degrees in (-180, 180]; 0: square to the page. + pub angle: f64, + /// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page. + pub rect: Rect, +} + +impl OrientedBox { + /// A box square to the page. + pub fn aligned(rect: Rect) -> Self { + Self { angle: 0.0, rect } + } + /// The turned frame → the page (a rotation about the page origin). + pub fn to_doc(&self) -> Affine { + if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) } + } + /// A page point in the turned frame. + pub fn to_local(&self, p: Point) -> Point { + self.to_doc().inverse() * p + } + /// `a`, a transform of the turned frame, as a page transform. + pub fn conjugate(&self, a: Affine) -> Affine { + if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() } + } + pub fn center(&self) -> Point { + self.to_doc() * self.rect.center() + } + /// One of the 9 reference points (row-major from the box's own top-left), on the page. + pub fn reference_point(&self, index: usize) -> Point { + self.to_doc() * vectorcraft_geom::reference_point(self.rect, index) + } + /// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself. + pub fn corners(&self) -> [Point; 4] { + let r = self.rect; + [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y)) + } +} + +/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis. +/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller +/// angle) is kept, so flipping an upright object keeps it upright. +pub fn transformed_angle(angle: f64, a: Affine) -> f64 { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) { + return angle; + } + let (s, c) = (-angle.to_radians()).sin_cos(); + let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s); + // Degenerate (or non-finite) transforms leave the angle alone. + let len = v.hypot(); + if !len.is_finite() || len <= 1e-12 { + return angle; + } + let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees()); + let dist = |x: f64| normalize_deg(x - angle).abs(); + if a.determinant() < 0.0 { + let alt = normalize_deg(out + 180.0); + let (d0, d1) = (dist(out), dist(alt)); + if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) { + out = alt; + } + } + // Moves and scales along the object's own axes keep its angle exactly. + if dist(out) <= EPS_DEG { + angle + } else if out.abs() <= EPS_DEG { + 0.0 + } else { + out + } +} + +impl Node { + /// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes), + /// without changing it. + pub fn bounds_in(&self, a: Affine, visual: bool) -> Option { + match &self.kind { + NodeKind::Group { children, clip: false } => { + let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual))); + // The group's own strokes paint around its members (as in `visual_bounds`). + let o = if visual { self.appearance.outset() } else { 0.0 }; + b.map(|b| b.inflate(o, o)) + } + NodeKind::Group { children, clip: true } => { + let clip = children.first()?; + let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)); + clip.bounds_in(a, visual && stroked) + } + _ => { + let mut n = self.clone(); + n.transform(a, false); + if visual { n.visual_bounds() } else { n.geometric_bounds() } + } + } + } +} + +impl Document { + /// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0. + pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 { + let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle); + let Some(first) = angles.next() else { return 0.0 }; + if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 } + } + /// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]): + /// geometric bounds, or `visual` with their strokes. + pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option { + let angle = self.bbox_angle(ids); + if angle == 0.0 { + return self.bounds_of(ids, visual).map(OrientedBox::aligned); + } + let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse(); + let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?; + Some(OrientedBox { angle, rect }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Appearance; + use vectorcraft_geom::shapes; + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + fn rot(deg: f64) -> Affine { + Affine::rotate(-deg.to_radians()) + } + + #[test] + fn rotations_add_and_moves_keep_the_angle() { + assert!(close(transformed_angle(0.0, rot(45.0)), 45.0)); + assert!(close(transformed_angle(170.0, rot(20.0)), -170.0)); + assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0); + assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0); + } + + #[test] + fn local_scales_keep_the_angle_exactly() { + let b = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0); + // A flip along the object's own axis keeps the box too. + let b = OrientedBox { angle: 135.0, rect: Rect::ZERO }; + assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0); + } + + #[test] + fn page_flips_mirror_the_angle() { + let flip_h = Affine::scale_non_uniform(-1.0, 1.0); + let flip_v = Affine::scale_non_uniform(1.0, -1.0); + assert_eq!(transformed_angle(0.0, flip_h), 0.0); + assert_eq!(transformed_angle(0.0, flip_v), 0.0); + assert!(close(transformed_angle(30.0, flip_h), -30.0)); + assert!(close(transformed_angle(30.0, flip_v), -30.0)); + // Degenerate transforms leave it alone. + assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0); + } + + #[test] + fn oriented_bounds_hug_a_rotated_rectangle() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap(); + let c = Point::new(50.0, 25.0); + d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false); + let b = d.oriented_bounds(&[id], false).unwrap(); + assert!(close(b.angle, 30.0)); + assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0)); + assert!(b.center().distance(c) < 1e-9); + // The page box is bigger. + assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0); + // Grouped: the group has no angle of its own, the bounds are square to the page. + let g = d.alloc_id(); + d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); + d.move_node(id, Some(g), 0).unwrap(); + assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0); + } +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index d1be6134..cdf6f155 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -86,9 +86,9 @@ pub fn specs() -> Vec { "Reset Bounding Box", ["Object", "Transform"], None, - "{} no-op: VectorCraft bounding boxes are always axis-aligned to the document → {changed: 0}", + "{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}", has_selection, - |_, _| Ok(json!({ "changed": 0 })) + reset_bounding_box ), cmd!( "object.rasterize", @@ -177,6 +177,25 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { } } +// ---------- Reset Bounding Box ---------- + +fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let d = &s.doc()?.doc; + let turned: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect(); + if !turned.is_empty() { + s.edit("Reset Bounding Box", |d, _| { + for id in &turned { + if let Some(n) = d.node_mut(*id) { + n.bbox_angle = 0.0; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": turned.len() })) +} + // ---------- Lock / Hide ---------- fn above(s: &mut Session, lock: bool) -> Result { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 1c08f1c1..991b7cc9 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Rotate…", ["Object", "Transform"], None, - "{angle: deg (counter-clockwise), origin?: [x,y], copy?}", + "{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}", has_selection, rotate ), @@ -175,7 +175,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", has_selection, set_bounds ), @@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { return Ok(o); } - s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) + s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) } /// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from @@ -255,8 +255,12 @@ fn about(o: Point, a: Affine) -> Affine { fn rotate(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let o = origin_of(s, p, &ids)?; - // Illustrator angles are counter-clockwise; y is down, so negate. - let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians())); + let mut angle = f64_or(p, "angle", 0.0); + if bool_or(p, "absolute", false) { + angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids)); + } + // Angles are counter-clockwise; y is down, so negate. + let a = about(o, Affine::rotate(-angle.to_radians())); apply_transform(s, "Rotate", ids, a, p) } @@ -716,6 +720,11 @@ impl Session { pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds) } + /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding + /// box, see [`Document::oriented_bounds`]). + pub fn transform_box(&self, ids: &[NodeId]) -> Option { + self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds) + } } /// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. @@ -876,10 +885,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let d = &s.doc()?.doc; - let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // Sizes are measured square to the bounding box, which a rotation turns with the objects: + // the math below runs in its frame. + let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + let g = frame.rect; // With Use Preview Bounds the values measure the visual box, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. - let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g }; + let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g }; let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1]; let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; let rp = vectorcraft_geom::reference_point(v, refi); @@ -906,9 +918,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { // box's reference point moves with the art, the margin from it to the visual one by `f`. let gp = vectorcraft_geom::reference_point(g, refi); let new_rp = scale * gp + (rp - gp) * f(sx, sy); - let tx = f64_or(p, "x", rp.x) - new_rp.x; - let ty = f64_or(p, "y", rp.y) - new_rp.y; - apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p) + // x, y place the reference point on the page. + let page = frame.to_doc() * rp; + let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); + apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 704af969..f7ebd27f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -25,6 +25,10 @@ pub fn node_summary(n: &Node) -> Value { if n.opacity < 1.0 { v["opacity"] = json!(n.opacity); } + // The angle of its rotated bounding box (counter-clockwise degrees). + if n.bbox_angle != 0.0 { + v["rotation"] = json!(n.bbox_angle); + } if !n.is_container() { v["fill"] = json!(n.appearance.fill_paint().label()); v["stroke"] = json!(n.appearance.stroke_paint().label()); @@ -64,6 +68,8 @@ pub fn document(s: &Session) -> Value { "isolation": st.isolation.map(|l| l.0), "selection": st.selection.objects.iter().map(|i| i.0).collect::>(), "selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)), + // The angle the selection's bounding box stands at (counter-clockwise degrees). + "selectionRotation": d.bbox_angle(&st.selection.objects), // The layer, group or object targeted through the Layers panel (`layer.target`). "target": st.selection.target.map(|t| t.0), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d95e3e5..08b2ac3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1094,6 +1094,8 @@ mod tests_assets; #[cfg(test)] mod tests_attributes; #[cfg(test)] +mod tests_bboxrotate; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; diff --git a/crates/engine/src/tests_bboxrotate.rs b/crates/engine/src/tests_bboxrotate.rs new file mode 100644 index 00000000..334e7b45 --- /dev/null +++ b/crates/engine/src/tests_bboxrotate.rs @@ -0,0 +1,211 @@ +//! Rotated bounding boxes (#39): a rotation keeps the objects' angle, the bounding box and its +//! handles turn with them, sizes are measured along their own axes, and Reset Bounding Box squares +//! the box to the page again. + +use serde_json::{Value, json}; +use vectorcraft_doc::OrientedBox; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::bbox::Handle; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.node(id).unwrap().bbox_angle +} + +fn selection_box(s: &Session) -> OrientedBox { + s.transform_box(&s.doc().unwrap().selection.objects).unwrap() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// A 100 × 50 rectangle centred on (200, 200), selected and turned to 45°. +fn turned_rect(s: &mut Session) -> NodeId { + let a = rect(s, 150.0, 175.0, 100.0, 50.0); + sel(s, &[a]); + s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap(); + a +} + +#[test] +fn rotating_to_45_keeps_45() { + let mut s = session(); + let n = undo_len(&s); + let a = turned_rect(&mut s); + assert_eq!(undo_len(&s), n + 2, "the rectangle, then one step for the rotation"); + // The panel's angle and the inspector's. + let b = selection_box(&s); + assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}"); + assert!(near(s.execute("document.inspect", &json!({})).unwrap()["selectionRotation"].as_f64().unwrap(), 45.0)); + // The box hugs the rectangle: its own sides, its own centre. + assert!(near(b.rect.width(), 100.0) && near(b.rect.height(), 50.0), "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6); + // Absolute angles: entering 45 again changes nothing, 90 turns it by 45 more. + s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap(); + assert!(near(angle(&s, a), 45.0)); + s.execute("object.rotate", &json!({"angle": 90, "absolute": true})).unwrap(); + assert!(near(angle(&s, a), 90.0)); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert!(near(b.width(), 50.0) && near(b.height(), 100.0), "{b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn handles_turn_with_the_object_and_scale_along_its_axes() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + // The Right handle sits on the turned box, 50 pt from the centre along 45° (up and right). + let right = b.to_doc() * Handle::Right.pos(b.rect); + let d = std::f64::consts::FRAC_1_SQRT_2 * 50.0; + assert!(right.distance(Point::new(200.0 + d, 200.0 - d)) < 1e-6, "{right:?}"); + // Drag it 50 pt further out along the object's own x axis. + let to = Point::new(right.x + d, right.y - d); + let n = undo_len(&s); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (kind, p) in [(PointerKind::Down, right), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap(); + } + assert_eq!(undo_len(&s), n + 1); + // 150 × 50 along its own axes, still at exactly 45°, the left side where it was. + assert_eq!(angle(&s, a), b.angle); + let nb = selection_box(&s); + assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}"); + let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect); + assert!(left(&nb).distance(left(&b)) < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn transform_panel_sizes_follow_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let n = undo_len(&s); + s.execute("object.setBounds", &json!({"width": 200, "reference": 4})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let b = selection_box(&s); + assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0) && near(b.angle, 45.0), "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6); + // X/Y place the box's own reference point on the page. + s.execute("object.setBounds", &json!({"x": 300, "y": 100, "reference": 0})).unwrap(); + let b = selection_box(&s); + assert!(b.reference_point(0).distance(Point::new(300.0, 100.0)) < 1e-6, "{b:?}"); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn reset_bounding_box_squares_the_box_in_one_step() { + let mut s = session(); + let a = turned_rect(&mut s); + let geometry = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let n = undo_len(&s); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(angle(&s, a), 0.0); + // The art stays; the box is square to the page around it. + let b = selection_box(&s); + assert_eq!(b.angle, 0.0); + assert_eq!(b.rect, geometry); + // Nothing left to reset: no step. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn page_flips_keep_an_upright_box_upright() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + sel(&mut s, &[a]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(angle(&s, a), 0.0); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + assert!(near(angle(&s, a), -30.0)); +} + +#[test] +fn groups_type_and_multiple_selections_keep_their_angle() { + let mut s = session(); + // Objects turned together share the box. + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + assert!(near(selection_box(&s).angle, 20.0) && near(angle(&s, a), 20.0) && near(angle(&s, b), 20.0)); + // Mixed angles: the box is square to the page. + let c = rect(&mut s, 100.0, 300.0, 50.0, 50.0); + sel(&mut s, &[a, c]); + assert_eq!(selection_box(&s).angle, 0.0); + // A group turns as one; its members keep their own angles inside it. + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + assert_eq!(selection_box(&s).angle, 0.0); + s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap(); + assert!(near(angle(&s, g), 30.0) && near(angle(&s, a), 50.0) && near(angle(&s, c), 30.0)); + s.execute("object.ungroup", &json!({})).unwrap(); + sel(&mut s, &[c]); + let cb = selection_box(&s); + assert!(near(cb.angle, 30.0) && near(cb.rect.width(), 50.0) && near(cb.rect.height(), 50.0), "{cb:?}"); + // Type keeps its angle too. + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Turn"})).unwrap()); + sel(&mut s, &[t]); + let before = selection_box(&s).rect; + s.execute("object.rotate", &json!({"angle": 60, "absolute": true})).unwrap(); + let tb = selection_box(&s); + assert!(near(tb.angle, 60.0), "{tb:?}"); + assert!(near(tb.rect.width(), before.width()) && near(tb.rect.height(), before.height()), "{tb:?} vs {before:?}"); +} + +#[test] +fn native_files_keep_the_angle() { + let mut s = session(); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let a = turned_rect(&mut s); + let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let text = String::from_utf8(vectorcraft_format::base64_decode(&data).unwrap()).unwrap(); + // Only turned objects write it. + assert_eq!(text.matches("bbox_angle").count(), 1); + s.execute("document.open", &json!({"name": "turned.vectorcraft", "dataBase64": data})).unwrap(); + assert!(near(angle(&s, a), 45.0)); + assert_eq!(angle(&s, plain), 0.0); + sel(&mut s, &[a]); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn rotated_box_rect_matches_page_bounds_when_upright() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 30.0, 40.0); + sel(&mut s, &[a]); + assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0))); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6adf8283..872f0313 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,10 +1,12 @@ //! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode. +//! The bounding box stands at the selection's own angle after a rotation, so its handles scale +//! along the objects' axes. use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::{hit_test, marquee}; +use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; @@ -22,7 +24,7 @@ enum State { }, Scaling { handle: Handle, - rect: Rect, + bx: OrientedBox, }, Rotating { center: Point, @@ -57,6 +59,28 @@ pub fn selection_bounds(cx: &ToolContext) -> Option { cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds) } +/// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool +/// shows and drags (rotated after a rotation). +pub fn selection_box(cx: &ToolContext) -> Option { + cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds) +} + +/// What of the bounding box is under the pointer. +enum BoxHit { + Handle(Handle), + /// Just outside a corner. + Rotate, +} + +/// The bounding-box handle or rotate zone under `p`. +fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { + let (tol, lp) = (cx.tol(5.0), b.to_local(p)); + if let Some(h) = hit_handle(b.rect, lp, tol) { + return Some(BoxHit::Handle(h)); + } + in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -98,16 +122,18 @@ impl Tool for SelectionTool { return vec![]; } if cx.show_bbox - && let Some(r) = selection_bounds(cx) + && let Some(bx) = selection_box(cx) { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { - self.state = State::Scaling { handle: h, rect: r }; - return vec![Action::Begin("Scale".into())]; - } - if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { - self.state = State::Rotating { center: r.center(), start: p }; - return vec![Action::Begin("Rotate".into())]; + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(handle)) => { + self.state = State::Scaling { handle, bx }; + return vec![Action::Begin("Scale".into())]; + } + Some(BoxHit::Rotate) => { + self.state = State::Rotating { center: bx.center(), start: p }; + return vec![Action::Begin("Rotate".into())]; + } + None => {} } } // 2. Objects. @@ -164,11 +190,12 @@ impl Tool for SelectionTool { out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out } - (PointerKind::Drag, State::Scaling { handle, rect, .. }) => { - let a = scale_for_drag(rect, handle, p, m.shift, m.alt); - let nr = a.transform_rect_bbox(rect); + (PointerKind::Drag, State::Scaling { handle, bx }) => { + // Scale in the box's own frame: along the objects' axes when it is rotated. + let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); + let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); - vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] + vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { let (a, deg) = rotate_for_drag(center, start, p, m.shift); @@ -234,7 +261,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { State::Rotating { .. } => return Cursor::Rotate, - State::Scaling { handle, .. } => return handle_cursor(handle), + State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, _ => {} @@ -243,14 +270,12 @@ impl Tool for SelectionTool { return Cursor::CornerRadius; } if cx.show_bbox - && let Some(r) = selection_bounds(cx) + && let Some(bx) = selection_box(cx) { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { - return handle_cursor(h); - } - if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { - return Cursor::Rotate; + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle), + Some(BoxHit::Rotate) => return Cursor::Rotate, + None => {} } } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { @@ -263,7 +288,11 @@ impl Tool for SelectionTool { } } -fn handle_cursor(h: Handle) -> Cursor { +/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the +/// handle that sits where `h` appears on screen, in 45° steps. +fn handle_cursor(h: Handle, angle: f64) -> Cursor { + let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; + let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h); match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, Handle::Left | Handle::Right => Cursor::ResizeH, @@ -339,6 +368,34 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } + #[test] + fn turned_box_handles_turn_and_scale_along_the_object() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2()); + d.node_mut(id).unwrap().transform(turn, false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // The Right handle sits up and to the right, where a diagonal cursor fits it. + let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2; + let right = Point::new(150.0 + k, 150.0 - k); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw); + // The page box's corner is no handle any more. + assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k)); + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + // Doubling the width along 45° about the left side: the left handle stays put. + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); + let left = Point::new(150.0 - k, 150.0 + k); + assert!((m * left).distance(left) < 1e-9); + assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9); + } + #[test] fn click_empty_deselects() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index df135121..869e3c6d 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; use crate::bbox::rotate_for_drag; use crate::guides::snap_pick; -use crate::select::{matrix_json, selection_bounds}; +use crate::select::{matrix_json, selection_bounds, selection_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -90,7 +90,7 @@ impl TransformTool { } /// The effective reference point: the custom one if it belongs to the current selection, - /// otherwise the selection centre. + /// otherwise the centre of its bounding box (rotated with rotated objects). pub fn reference_point(&self, cx: &ToolContext) -> Option { if let Some((p, ids)) = &self.origin && *ids == cx.selection.objects @@ -98,7 +98,7 @@ impl TransformTool { { return Some(*p); } - selection_bounds(cx).map(|b| b.center()) + selection_box(cx).map(|b| b.center()) } /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4b9dde39..6766c5bb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -996,7 +996,13 @@ fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { /// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard. const PRINT_TILING: Color32 = Color32::from_gray(96); -fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { +fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + // The Selection tool's bounding box (rotated with the objects after a rotation). + let show_box = app.session.tool_id() == "selection" + && app.ui.view.bounding_box + && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); + let bbox = if show_box { app.selection_box() } else { None }; + let app = &*app; let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature"); @@ -1061,12 +1067,11 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { } } // Bounding box with handles (Selection tool). - if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { - let Some(b) = app.session.transform_bounds(&st.selection.objects) else { return }; + if let Some(b) = bbox { let color = c32(st.doc.layer_color(st.selection.objects[0])); - p.add(Shape::closed_line(xf.quad(b), Stroke::new(1.0, color))); + p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); for h in vectorcraft_tools::bbox::Handle::ALL { - let c = xf.to_screen(h.pos(b)); + let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0)); p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6e309082..77ed1d69 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -200,11 +200,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); // Transform fields. - let b = app.session.active().and_then(|s| app.session.transform_bounds(&s.selection.objects)); - if let Some(b) = b { - for (k, lbl, v) in - [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] - { + // The bounding box, rotated with rotated objects: its centre and its own sides. + if let Some(b) = app.selection_box() { + let c = b.center(); + for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] { ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) { app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9162d4ff..bda31d7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -188,6 +188,8 @@ pub struct CanvasCache { pub slices: Option, /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). pub print_tiling: Option, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). + pub selection_box: Option<((u64, u64, bool), Option)>, } /// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). @@ -302,6 +304,7 @@ impl VectorcraftApp { worker_started: false, slices: None, print_tiling: None, + selection_box: None, }, perf: Perf::default(), integrated_titlebar: false, @@ -562,6 +565,23 @@ pub fn now_ms() -> f64 { } } +impl VectorcraftApp { + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The + /// canvas and the transform fields read it every frame: it is measured once per revision. + pub fn selection_box(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + if let Some((k, b)) = self.canvas.selection_box + && k == key + { + return b; + } + let b = self.session.transform_box(&st.selection.objects); + self.canvas.selection_box = Some((key, b)); + b + } +} + /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are /// called from the host's `eframe::App` impl. impl VectorcraftApp { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e200e9fb..d99ec6f8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -250,14 +250,18 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { } pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; + if app.session.active().is_none() { + return; + } let units = app.session.general_unit(); - let Some(b) = app.session.transform_bounds(&st.selection.objects) else { + // The bounding box, rotated with rotated objects (as in the Transform panel). + let Some(bx) = app.selection_box() else { dim_label(ui, "No Selection"); return; }; + let b = bx.rect; let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); - let rp = vectorcraft_geom::reference_point(b, refi); + let rp = bx.reference_point(refi); section_header(ui, "Transform"); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { @@ -288,8 +292,9 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.horizontal(|ui| { icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon); - if let Some(a) = widgets::plain_field(ui, "rot", 0.0, "°", 2, 70.0) { - app.run("object.rotate", json!({"angle": a})).ok(); + // The bounding box's angle: a new value turns the selection to it. + if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) { + app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); } ui.add_space(10.0); if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() { diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 276ab79f..823b0bc0 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -23,16 +23,19 @@ pub fn constrained(w: f64, h: f64, new_w: Option, new_h: Option) -> (f pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { + if app.session.active().is_none() { widgets::dim_label(ui, "No document"); return; - }; + } let units = app.session.general_unit(); - let bounds = app.session.transform_bounds(&st.selection.objects); + // The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference + // point on the page. + let bx = app.selection_box(); + let bounds = bx.map(|b| b.rect); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); let link: bool = pstate(ui.ctx(), "xf-link"); let has = bounds.is_some(); - let rp = bounds.map(|b| vectorcraft_geom::reference_point(b, refi)); + let rp = bx.map(|b| b.reference_point(refi)); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); @@ -69,11 +72,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_enabled_ui(has, |ui| { icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text("Rotate"); - // Rotation is applied relative to the current orientation (0° shown after each apply). - if let Some(a) = widgets::spin_plain(ui, "xfp-rot", 0.0, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) - && a != 0.0 + // The bounding box's angle: a new value turns the selection to it. + let angle = bx.map_or(0.0, |b| b.angle); + if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) + && a != angle { - app.run("object.rotate", json!({"angle": a, "origin": origin})).ok(); + app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok(); } ui.add_space(4.0); icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text("Shear"); @@ -204,6 +208,21 @@ mod tests { assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); } + #[test] + fn rotation_field_shows_the_angle_the_box_keeps() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap(); + let shown = texts(&mut app, show); + // The angle stays, and W/H are the rectangle's own sides. + assert!(shown.iter().any(|t| t == "45°"), "{shown:?}"); + assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); + app.run("object.resetBoundingBox", json!({})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}"); + } + #[test] fn constrain_proportions() { assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); diff --git a/docs/mcp.md b/docs/mcp.md index 61d16623..fda3080b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1640,3 +1640,17 @@ CMYK one. Settings saved before these options load with their defaults. ```json {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/proof.pdf","settings":{"advanced":{"overprints":"simulate","flattenerPreset":"High Resolution"},"color":{"profile":"VectorCraft Generic CMYK (SWOP-like)","intent":"perceptual"}}}}} ``` + +### Rotated bounding boxes + +Every object keeps the angle of its own axes (counter-clockwise degrees; `document.inspect` reports it as a node's +`rotation`, and the selection's shared angle as `selectionRotation`, 0 when the selected objects differ). Transforms +turn it with the object while its path geometry is still baked, so after a rotation the bounding box and its handles +stay square to it, as in the reference app. `object.rotate {angle, absolute: true}` turns the selection to an angle +(the Transform and Properties panels' Rotate field), and `object.setBounds` measures width, height and the reference +point along the turned box (x, y stay page coordinates). Objects turned together share the box. A new group starts +square to the page. `object.resetBoundingBox` squares the box again without moving the art. + +```json +{"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}} +```