mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d3d6cce917
commit
96604acffb
@@ -86,9 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<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 ----------
|
||||
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Point> {
|
||||
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<Value> {
|
||||
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<Rect> {
|
||||
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.
|
||||
@@ -876,10 +885,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user