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:
bflatastic
2026-10-05 23:49:36 +07:00
co-authored by Claude Opus 5.5
parent d3d6cce917
commit 96604acffb
17 changed files with 641 additions and 65 deletions
+21 -2
View File
@@ -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> {
+24 -11
View File
@@ -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> {