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 <noreply@anthropic.com>
This commit is contained in:
parent
d3d6cce917
commit
96604acffb
|
|
@ -137,7 +137,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod live;
|
||||||
pub mod marks;
|
pub mod marks;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
|
pub mod orient;
|
||||||
pub mod overprint;
|
pub mod overprint;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
mod pixels;
|
mod pixels;
|
||||||
|
|
@ -61,6 +62,7 @@ pub use node::Knockout;
|
||||||
pub use node::Scaling;
|
pub use node::Scaling;
|
||||||
pub use node::{ImageMap, ObjectAttributes};
|
pub use node::{ImageMap, ObjectAttributes};
|
||||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
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 pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||||
pub use profiles::ColorProfiles;
|
pub use profiles::ColorProfiles;
|
||||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,11 @@ pub struct Node {
|
||||||
/// these options.
|
/// these options.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub slice: Option<Box<crate::SliceOptions>>,
|
pub slice: Option<Box<crate::SliceOptions>>,
|
||||||
|
/// 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).
|
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||||
|
|
@ -383,6 +388,7 @@ impl Node {
|
||||||
graphic_style: None,
|
graphic_style: None,
|
||||||
attrs: None,
|
attrs: None,
|
||||||
slice: None,
|
slice: None,
|
||||||
|
bbox_angle: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||||
|
|
@ -572,6 +578,9 @@ impl Node {
|
||||||
self.transform_scaled(a, &scaling.into());
|
self.transform_scaled(a, &scaling.into());
|
||||||
}
|
}
|
||||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
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.
|
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||||
if !keeps_gradient_fit(a) {
|
if !keeps_gradient_fit(a) {
|
||||||
self.pin_gradients();
|
self.pin_gradients();
|
||||||
|
|
|
||||||
|
|
@ -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<Rect> {
|
||||||
|
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<OrientedBox> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,9 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Reset Bounding Box",
|
"Reset Bounding Box",
|
||||||
["Object", "Transform"],
|
["Object", "Transform"],
|
||||||
None,
|
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,
|
has_selection,
|
||||||
|_, _| Ok(json!({ "changed": 0 }))
|
reset_bounding_box
|
||||||
),
|
),
|
||||||
cmd!(
|
cmd!(
|
||||||
"object.rasterize",
|
"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<Value> {
|
||||||
|
let ids = selected_roots(s)?;
|
||||||
|
let d = &s.doc()?.doc;
|
||||||
|
let turned: Vec<NodeId> = 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 ----------
|
// ---------- Lock / Hide ----------
|
||||||
|
|
||||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_color::{BlendMode, Paint};
|
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 vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||||
|
|
||||||
use super::edit::{duplicate_in, selected_roots};
|
use super::edit::{duplicate_in, selected_roots};
|
||||||
|
|
@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Rotate…",
|
"Rotate…",
|
||||||
["Object", "Transform"],
|
["Object", "Transform"],
|
||||||
None,
|
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,
|
has_selection,
|
||||||
rotate
|
rotate
|
||||||
),
|
),
|
||||||
|
|
@ -175,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Set Bounds",
|
"Set Bounds",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_selection,
|
||||||
set_bounds
|
set_bounds
|
||||||
),
|
),
|
||||||
|
|
@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||||
if let Some(o) = point_param(p, "origin") {
|
if let Some(o) = point_param(p, "origin") {
|
||||||
return Ok(o);
|
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
|
/// 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<Value> {
|
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = selected_roots(s)?;
|
let ids = selected_roots(s)?;
|
||||||
let o = origin_of(s, p, &ids)?;
|
let o = origin_of(s, p, &ids)?;
|
||||||
// Illustrator angles are counter-clockwise; y is down, so negate.
|
let mut angle = f64_or(p, "angle", 0.0);
|
||||||
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
|
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)
|
apply_transform(s, "Rotate", ids, a, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -716,6 +720,11 @@ impl Session {
|
||||||
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||||
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
|
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<OrientedBox> {
|
||||||
|
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure with preview (visual) bounds? The `bounds` param, else 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<Value> {
|
||||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = selected_roots(s)?;
|
let ids = selected_roots(s)?;
|
||||||
let d = &s.doc()?.doc;
|
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
|
// 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.
|
// 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 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 refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||||
|
|
@ -906,9 +918,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
// box's reference point moves with the art, the margin from it to the visual one by `f`.
|
// 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 gp = vectorcraft_geom::reference_point(g, refi);
|
||||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||||
let tx = f64_or(p, "x", rp.x) - new_rp.x;
|
// x, y place the reference point on the page.
|
||||||
let ty = f64_or(p, "y", rp.y) - new_rp.y;
|
let page = frame.to_doc() * rp;
|
||||||
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p)
|
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<Value> {
|
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ pub fn node_summary(n: &Node) -> Value {
|
||||||
if n.opacity < 1.0 {
|
if n.opacity < 1.0 {
|
||||||
v["opacity"] = json!(n.opacity);
|
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() {
|
if !n.is_container() {
|
||||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||||
v["stroke"] = json!(n.appearance.stroke_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),
|
"isolation": st.isolation.map(|l| l.0),
|
||||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
"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`).
|
// The layer, group or object targeted through the Layers panel (`layer.target`).
|
||||||
"target": st.selection.target.map(|t| t.0),
|
"target": st.selection.target.map(|t| t.0),
|
||||||
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||||
|
|
|
||||||
|
|
@ -1094,6 +1094,8 @@ mod tests_assets;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_attributes;
|
mod tests_attributes;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_bboxrotate;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_brushsym;
|
mod tests_brushsym;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_build;
|
mod tests_build;
|
||||||
|
|
|
||||||
|
|
@ -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::<Vec<_>>()})).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)));
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
|
//! 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°),
|
//! 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.
|
//! 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 serde_json::{Value, json};
|
||||||
use vectorcraft_doc::NodeId;
|
|
||||||
use vectorcraft_doc::hit::{hit_test, marquee};
|
use vectorcraft_doc::hit::{hit_test, marquee};
|
||||||
|
use vectorcraft_doc::{NodeId, OrientedBox};
|
||||||
use vectorcraft_geom::{Affine, Point, Rect};
|
use vectorcraft_geom::{Affine, Point, Rect};
|
||||||
|
|
||||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||||
|
|
@ -22,7 +24,7 @@ enum State {
|
||||||
},
|
},
|
||||||
Scaling {
|
Scaling {
|
||||||
handle: Handle,
|
handle: Handle,
|
||||||
rect: Rect,
|
bx: OrientedBox,
|
||||||
},
|
},
|
||||||
Rotating {
|
Rotating {
|
||||||
center: Point,
|
center: Point,
|
||||||
|
|
@ -57,6 +59,28 @@ pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
|
||||||
cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds)
|
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<OrientedBox> {
|
||||||
|
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<BoxHit> {
|
||||||
|
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 {
|
impl SelectionTool {
|
||||||
fn drag_threshold(cx: &ToolContext) -> f64 {
|
fn drag_threshold(cx: &ToolContext) -> f64 {
|
||||||
cx.tol(3.0)
|
cx.tol(3.0)
|
||||||
|
|
@ -98,16 +122,18 @@ impl Tool for SelectionTool {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
if cx.show_bbox
|
if cx.show_bbox
|
||||||
&& let Some(r) = selection_bounds(cx)
|
&& let Some(bx) = selection_box(cx)
|
||||||
{
|
{
|
||||||
let tol = cx.tol(5.0);
|
match box_hit(cx, &bx, p) {
|
||||||
if let Some(h) = hit_handle(r, p, tol) {
|
Some(BoxHit::Handle(handle)) => {
|
||||||
self.state = State::Scaling { handle: h, rect: r };
|
self.state = State::Scaling { handle, bx };
|
||||||
return vec![Action::Begin("Scale".into())];
|
return vec![Action::Begin("Scale".into())];
|
||||||
}
|
}
|
||||||
if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
Some(BoxHit::Rotate) => {
|
||||||
self.state = State::Rotating { center: r.center(), start: p };
|
self.state = State::Rotating { center: bx.center(), start: p };
|
||||||
return vec![Action::Begin("Rotate".into())];
|
return vec![Action::Begin("Rotate".into())];
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Objects.
|
// 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.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
(PointerKind::Drag, State::Scaling { handle, rect, .. }) => {
|
(PointerKind::Drag, State::Scaling { handle, bx }) => {
|
||||||
let a = scale_for_drag(rect, handle, p, m.shift, m.alt);
|
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||||||
let nr = a.transform_rect_bbox(rect);
|
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())));
|
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, .. }) => {
|
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
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 {
|
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||||
match self.state {
|
match self.state {
|
||||||
State::Rotating { .. } => return Cursor::Rotate,
|
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::Moving { began: true, .. } => return Cursor::Arrow,
|
||||||
State::Corner(_) => return Cursor::CornerRadius,
|
State::Corner(_) => return Cursor::CornerRadius,
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -243,14 +270,12 @@ impl Tool for SelectionTool {
|
||||||
return Cursor::CornerRadius;
|
return Cursor::CornerRadius;
|
||||||
}
|
}
|
||||||
if cx.show_bbox
|
if cx.show_bbox
|
||||||
&& let Some(r) = selection_bounds(cx)
|
&& let Some(bx) = selection_box(cx)
|
||||||
{
|
{
|
||||||
let tol = cx.tol(5.0);
|
match box_hit(cx, &bx, p) {
|
||||||
if let Some(h) = hit_handle(r, p, tol) {
|
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
|
||||||
return handle_cursor(h);
|
Some(BoxHit::Rotate) => return Cursor::Rotate,
|
||||||
}
|
None => {}
|
||||||
if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
|
||||||
return Cursor::Rotate;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
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 {
|
match h {
|
||||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
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]);
|
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<f64> = 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]
|
#[test]
|
||||||
fn click_empty_deselects() {
|
fn click_empty_deselects() {
|
||||||
let (d, _) = doc_with_rect();
|
let (d, _) = doc_with_rect();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||||
use super::target_overlays;
|
use super::target_overlays;
|
||||||
use crate::bbox::rotate_for_drag;
|
use crate::bbox::rotate_for_drag;
|
||||||
use crate::guides::snap_pick;
|
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};
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[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,
|
/// 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<Point> {
|
pub fn reference_point(&self, cx: &ToolContext) -> Option<Point> {
|
||||||
if let Some((p, ids)) = &self.origin
|
if let Some((p, ids)) = &self.origin
|
||||||
&& *ids == cx.selection.objects
|
&& *ids == cx.selection.objects
|
||||||
|
|
@ -98,7 +98,7 @@ impl TransformTool {
|
||||||
{
|
{
|
||||||
return Some(*p);
|
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.
|
/// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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);
|
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 Some(st) = app.session.active() else { return };
|
||||||
let tool = app.session.tool_id();
|
let tool = app.session.tool_id();
|
||||||
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
|
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).
|
// Bounding box with handles (Selection tool).
|
||||||
if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
|
if let Some(b) = bbox {
|
||||||
let Some(b) = app.session.transform_bounds(&st.selection.objects) else { return };
|
|
||||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
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 {
|
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));
|
let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0));
|
||||||
p.rect_filled(hr, 0.0, Color32::WHITE);
|
p.rect_filled(hr, 0.0, Color32::WHITE);
|
||||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||||||
|
|
|
||||||
|
|
@ -200,11 +200,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
// Transform fields.
|
// Transform fields.
|
||||||
let b = app.session.active().and_then(|s| app.session.transform_bounds(&s.selection.objects));
|
// The bounding box, rotated with rotated objects: its centre and its own sides.
|
||||||
if let Some(b) = b {
|
if let Some(b) = app.selection_box() {
|
||||||
for (k, lbl, v) in
|
let c = b.center();
|
||||||
[("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())]
|
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));
|
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) {
|
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();
|
app.run("object.setBounds", json!({k: nv, "reference": 4})).ok();
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ pub struct CanvasCache {
|
||||||
pub slices: Option<SliceCache>,
|
pub slices: Option<SliceCache>,
|
||||||
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
|
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
|
||||||
pub print_tiling: Option<PrintTilingCache>,
|
pub print_tiling: Option<PrintTilingCache>,
|
||||||
|
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
|
||||||
|
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||||
|
|
@ -302,6 +304,7 @@ impl VectorcraftApp {
|
||||||
worker_started: false,
|
worker_started: false,
|
||||||
slices: None,
|
slices: None,
|
||||||
print_tiling: None,
|
print_tiling: None,
|
||||||
|
selection_box: None,
|
||||||
},
|
},
|
||||||
perf: Perf::default(),
|
perf: Perf::default(),
|
||||||
integrated_titlebar: false,
|
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<vectorcraft_doc::OrientedBox> {
|
||||||
|
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
|
/// 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.
|
/// called from the host's `eframe::App` impl.
|
||||||
impl VectorcraftApp {
|
impl VectorcraftApp {
|
||||||
|
|
|
||||||
|
|
@ -250,14 +250,18 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transform_section(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 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");
|
dim_label(ui, "No Selection");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let b = bx.rect;
|
||||||
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
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");
|
section_header(ui, "Transform");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(i) = widgets::reference_point(ui, refi) {
|
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| {
|
ui.horizontal(|ui| {
|
||||||
icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
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) {
|
// The bounding box's angle: a new value turns the selection to it.
|
||||||
app.run("object.rotate", json!({"angle": a})).ok();
|
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);
|
ui.add_space(10.0);
|
||||||
if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() {
|
if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() {
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,19 @@ pub fn constrained(w: f64, h: f64, new_w: Option<f64>, new_h: Option<f64>) -> (f
|
||||||
|
|
||||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
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");
|
widgets::dim_label(ui, "No document");
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let units = app.session.general_unit();
|
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 refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
||||||
let link: bool = pstate(ui.ctx(), "xf-link");
|
let link: bool = pstate(ui.ctx(), "xf-link");
|
||||||
let has = bounds.is_some();
|
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| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(i) = widgets::reference_point(ui, refi) {
|
if let Some(i) = widgets::reference_point(ui, refi) {
|
||||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
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.horizontal(|ui| {
|
||||||
ui.add_enabled_ui(has, |ui| {
|
ui.add_enabled_ui(has, |ui| {
|
||||||
icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text("Rotate");
|
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).
|
// The bounding box's angle: a new value turns the selection to it.
|
||||||
if let Some(a) = widgets::spin_plain(ui, "xfp-rot", 0.0, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS)
|
let angle = bx.map_or(0.0, |b| b.angle);
|
||||||
&& a != 0.0
|
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);
|
ui.add_space(4.0);
|
||||||
icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text("Shear");
|
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"));
|
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]
|
#[test]
|
||||||
fn constrain_proportions() {
|
fn constrain_proportions() {
|
||||||
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
||||||
|
|
|
||||||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -1640,3 +1640,17 @@ CMYK one. Settings saved before these options load with their defaults.
|
||||||
```json
|
```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"}}}}}
|
{"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}}}
|
||||||
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue