Merge upstream/main into docs/drag-modifiers-measured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-12 02:37:47 -05:00
co-authored by Claude Opus 5.5
10 changed files with 182 additions and 13 deletions
+11 -2
View File
@@ -113,8 +113,12 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
// A new rotation for the selection turns it about its moved rotation origin: each layer's
// point under the origin stays put.
let ds = s.ds()?;
let pivot =
ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids);
let pivot = ds.moved_origin().filter(|_| {
(props.contains_key("rotation") || props.contains_key("absoluteRotation"))
&& !props.contains_key("x")
&& !props.contains_key("y")
&& ds.selection == ids
});
let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot {
Some(o) => roots(&ds.doc, &ids)
.into_iter()
@@ -161,6 +165,11 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc_mut()?;
let before = crate::cmd::styles::bound_before(doc, &ids);
let mut ids = ids;
if props.contains_key("absoluteRotation") {
// A page angle depends on the parents': turn a selected parent before the layers in it.
ids.sort_by_key(|i| doc.ancestors(*i).len());
}
for i in ids {
crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
+23 -2
View File
@@ -16,7 +16,7 @@ use crate::{Result, bad};
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), absoluteRotation (degrees on the page: the layer's angle plus its parents', what the Design panel's field shows; one of the two per call), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], dashCap (none|round|square; null: dashes take startCap and endCap), widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
@@ -456,7 +456,7 @@ fn apply_order(k: &str, v: &Value) -> u8 {
match k {
"path" | "width" | "height" | "w" | "h" => 0,
"layout" | "layoutMode" => 1,
"rotation" => 3,
"rotation" | "absoluteRotation" => 3,
"x" | "y" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
"boundVariables" => 6,
@@ -595,6 +595,27 @@ fn apply_geometry(
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
"absoluteRotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`absoluteRotation` must be degrees"))?;
if props.contains_key("rotation") {
return Err(bad(cmd, "give one of `rotation` and `absoluteRotation`"));
}
let (w, h) = {
let n = doc.get(id)?;
(n.width, n.height)
};
let world = figcraft_geom::set_rotation_about_center(doc.world_transform(id), w, h, deg);
let parent = doc.get(id)?.parent;
let pw = parent.map(|p| doc.world_transform(p)).unwrap_or(figcraft_geom::Affine::IDENTITY);
let local = pw.inverse() * world;
if local.as_coeffs().iter().all(|c| c.is_finite()) {
doc.get_mut(id)?.transform = local;
} else {
// A degenerate parent can't be inverted: turn the local angle instead.
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
}
_ => return Ok(false),
}
Ok(true)
@@ -90,6 +90,17 @@ fn rotation_field_and_rotate_commands_turn_about_a_moved_origin() {
assert!(s.execute("transform.setRotationOrigin", &json!({"x": 1})).is_err(), "x without y");
}
#[test]
fn the_absolute_rotation_key_turns_about_a_moved_origin_too() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.setRotationOrigin", &json!({"x": 100, "y": 100})).unwrap();
s.execute("node.set", &json!({"ids": [r], "props": {"absoluteRotation": 30}})).unwrap();
let doc = s.doc().unwrap();
assert!(near(doc.world_transform(r) * Point::new(100.0, 100.0), Point::new(100.0, 100.0)));
assert!((figcraft_geom::rotation_degrees(doc.world_transform(r)) - 30.0).abs() < 1e-6);
}
#[test]
fn origin_lasts_as_long_as_its_selection() {
let mut s = Session::new();
@@ -533,6 +533,61 @@ fn design_panel_edits_reach_every_selected_layer() {
assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a));
}
#[test]
fn absolute_rotation_sets_the_rotation_on_the_page_keeping_the_centre() {
let mut s = session();
let page = |s: &Session, i: NodeId| rotation_degrees(s.doc().unwrap().world_transform(i));
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": 10}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
assert!(near(page(&s, r), 10.0) && near(rotation(&s, r), 0.0));
let centre = s.doc().unwrap().world_transform(r) * Point::new(60.0, 40.0);
set(&mut s, &[r], json!({"absoluteRotation": 7}));
assert!(near(page(&s, r), 7.0), "{}", page(&s, r));
assert!(near(rotation(&s, r), -3.0), "{}", rotation(&s, r));
let c = s.doc().unwrap().world_transform(r) * Point::new(60.0, 40.0);
assert!((c - centre).hypot() < 1e-6, "the centre stays put");
set(&mut s, &[r], json!({"absoluteRotation": 0}));
assert!(near(page(&s, r), 0.0) && near(rotation(&s, r), -10.0));
// `node.get` still reports the local angle.
let got = ex(&mut s, "node.get", json!({"id": r}));
let rot = got["rotation"].as_f64().unwrap_or(f64::NAN);
assert!(near(rot, -10.0), "{got}");
// Groups: a child reads and sets the page angle, its sibling keeps its own.
let a = rect(&mut s, 0.0, 600.0, 50.0, 50.0, None);
let b = rect(&mut s, 100.0, 600.0, 50.0, 50.0, None);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
set(&mut s, &[g], json!({"rotation": 10}));
assert!(near(page(&s, a), 10.0));
set(&mut s, &[a], json!({"absoluteRotation": 7}));
assert!(near(page(&s, a), 7.0) && near(page(&s, b), 10.0));
// Several layers: one value on the page for all, one undo step.
let top = rect(&mut s, 800.0, 0.0, 50.0, 50.0, None);
let steps = undo_steps(&s);
set(&mut s, &[r, top], json!({"absoluteRotation": 45}));
assert!(near(page(&s, r), 45.0) && near(page(&s, top), 45.0));
assert_eq!(undo_steps(&s), steps + 1);
// A flip is kept.
ex(&mut s, "object.flipH", json!({"ids": [r]}));
set(&mut s, &[r], json!({"absoluteRotation": 30}));
assert!(near(page(&s, r), 30.0) && flipped(&s, r), "{}", page(&s, r));
// One of the two keys per call.
assert!(s.execute("node.set", &json!({"ids": [r], "props": {"rotation": 1, "absoluteRotation": 2}})).is_err());
}
#[test]
fn absolute_rotation_on_a_frame_and_a_layer_inside_it_lands_both_on_the_page_angle() {
let mut s = session();
let page = |s: &Session, i: NodeId| rotation_degrees(s.doc().unwrap().world_transform(i));
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": 10}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
// The layer inside comes first in the ids: the frame's turn must not carry it past 45.
set(&mut s, &[r, f], json!({"absoluteRotation": 45}));
assert!(near(page(&s, f), 45.0), "{}", page(&s, f));
assert!(near(page(&s, r), 45.0), "{}", page(&s, r));
}
#[test]
fn constrain_proportions_keeps_the_other_side_in_ratio() {
let mut s = session();
+1 -1
View File
@@ -208,7 +208,7 @@ fn tools() -> Vec<Value> {
tool(
"set_properties",
"Set properties",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), absoluteRotation (degrees on the page), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
obj(
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
&["props"],
+62
View File
@@ -441,6 +441,68 @@ fn the_arrow_keys_step_by_the_nudge_amounts_and_fractional_fields_by_their_own()
assert!((rot.abs() - 11.0).abs() < 1e-6, "rotation {rot}: the steps stay after Escape");
}
#[test]
fn the_rotation_field_shows_and_sets_the_rotation_on_the_page() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 400, "height": 400})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"rotation": 10}})).unwrap();
let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 120, "height": 80, "parent": f})).unwrap());
s.execute("node.set", &json!({"ids": [r], "props": {"rotation": -10}})).unwrap();
let b = id(s.execute("shape.rectangle", &json!({"x": 600, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let page = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().world_transform(n));
let local = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().node(n).unwrap().transform);
assert_eq!(field(&mut u, "Rotation").1, "0°");
type_into(&mut u, "Rotation", "7", Key::Enter);
assert!((page(&u, r) - 7.0).abs() < 1e-6 && (local(&u, r) + 3.0).abs() < 1e-6, "{} {}", page(&u, r), local(&u, r));
assert_eq!(field(&mut u, "Rotation").1, "7°");
// Arrows step the shown value.
let shift = Modifiers { shift: true, ..Modifiers::NONE };
let (p, _) = field(&mut u, "Rotation");
click(&mut u, p);
key(&mut u, Key::ArrowUp, Modifiers::NONE);
key(&mut u, Key::ArrowUp, shift);
key(&mut u, Key::Escape, Modifiers::NONE);
assert!((page(&u, r) - 18.0).abs() < 1e-6, "{}", page(&u, r));
assert_eq!(field(&mut u, "Rotation").1, "18°");
// Scrubbing is one undo step.
let steps = undo_steps(&u);
scrub(&mut u, "Rotation", 15.0, Modifiers::NONE);
assert!((page(&u, r) - 33.0).abs() < 1e-6, "{}", page(&u, r));
assert_eq!(undo_steps(&u), steps + 1);
// Mixed: layers in differently turned parents agree when their page angles do.
u.app.session.execute("node.set", &json!({"ids": [b], "props": {"rotation": 33}})).unwrap();
u.app.session.execute("select.set", &json!({"ids": [r, b]})).unwrap();
assert_eq!(field(&mut u, "Rotation").1, "33°");
u.app.session.execute("node.set", &json!({"ids": [b], "props": {"rotation": 20}})).unwrap();
assert_eq!(field(&mut u, "Rotation").1, "Mixed");
let steps = undo_steps(&u);
type_into(&mut u, "Rotation", "+10", Key::Enter);
assert!((page(&u, r) - 43.0).abs() < 1e-6 && (page(&u, b) - 30.0).abs() < 1e-6, "{} {}", page(&u, r), page(&u, b));
assert_eq!(undo_steps(&u), steps + 1);
}
#[test]
fn a_relative_rotation_edit_turns_a_selected_parent_before_its_child_whatever_the_selection_order() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 400})).unwrap());
let c = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 120, "height": 80, "parent": f})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"rotation": 10}})).unwrap();
s.execute("node.set", &json!({"ids": [c], "props": {"absoluteRotation": 30}})).unwrap();
// The child first, then its parent.
s.execute("select.set", &json!({"ids": [c, f]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let page = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().world_transform(n));
assert_eq!(field(&mut u, "Rotation").1, "Mixed");
type_into(&mut u, "Rotation", "+10", Key::Enter);
assert!((page(&u, f) - 20.0).abs() < 1e-6 && (page(&u, c) - 40.0).abs() < 1e-6, "{} {}", page(&u, f), page(&u, c));
}
#[test]
fn arithmetic_commits_tab_moves_on_and_escape_restores() {
let (mut u, a, _) = setup();
+13 -7
View File
@@ -75,7 +75,12 @@ impl FigApp {
None => return,
};
let Ok(doc) = self.session.doc() else { return };
let values: Vec<(NodeId, f64)> = ids.iter().filter_map(|i| Some((*i, rel.apply(shown(doc.node(*i)?))?))).collect();
let mut values: Vec<(NodeId, f64)> = ids.iter().filter_map(|i| Some((*i, rel.apply(shown(doc.node(*i)?))?))).collect();
if key == "absoluteRotation" {
// A page angle depends on the parents': turn a selected parent before the layers in it
// (as `node.set` orders a multi-id call), whatever order the selection is in.
values.sort_by_key(|(i, _)| doc.ancestors(*i).len());
}
if values.is_empty() || self.session.begin_interaction("Edit").is_err() {
return;
}
@@ -627,8 +632,9 @@ fn y_of(doc: &Document, n: &Node) -> f64 {
(doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0
}
fn rot_of(n: &Node) -> f64 {
(figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0
/// The layer's rotation on the page (its own angle plus its parents'), what the field shows.
fn rot_of(doc: &Document, n: &Node) -> f64 {
(figcraft_geom::rotation_degrees(doc.world_transform(n.id)) * 100.0).round() / 100.0
}
pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> PositionValues {
@@ -637,7 +643,7 @@ pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[N
can_align: figcraft_engine::cmd::arrange::can_align(doc, sel),
x: common(doc, sel, |n| x_of(doc, n)),
y: common(doc, sel, |n| y_of(doc, n)),
rot: common(doc, sel, rot_of),
rot: common(doc, sel, |n| rot_of(doc, n)),
parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())),
absolute: common(doc, sel, |n| n.child_layout.absolute),
// Constraints: children of frames without auto layout, or ignoring it.
@@ -655,7 +661,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[No
let col = widgets::col_width(ui.available_width());
align_row(app, ui, sel, pv.can_align, col);
position_row(app, ui, doc, sel, &pv, col);
rotation_row(app, ui, sel, pv.rot, col);
rotation_row(app, ui, doc, sel, pv.rot, col);
if pv.in_frame {
constraints_row(app, ui, sel, pv.constraints, col);
}
@@ -732,12 +738,12 @@ fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], p
}
/// Rotation, with the rotate and flip buttons.
fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option<f64>, col: f32) {
fn rotation_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], rot: Option<f64>, col: f32) {
let ids = sel.to_vec();
widgets::sublabels(ui, tr("Rotation"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
app.field_of(&ids, "rotation", e, rot_of, |v| json!(v));
app.field_of(&ids, "absoluteRotation", e, |n| rot_of(doc, n), |v| json!(v));
let items = [
(Icon::Rotate, tr("Rotate 90° right")),
(Icon::FlipH, crate::shortcuts::tip(tr("Flip horizontal"), "object.flipH")),
+1
View File
@@ -67,6 +67,7 @@ state are answered without a frame (about half a second each), while `ui.screens
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
frame (groups have no coordinate space of their own).
`rotation` in `node.set` is the layer's angle to its parent; `absoluteRotation` sets its rotation on the page (what the Design panel's field shows); a call takes one of the two.
**Windows.** The desktop app can show files in several windows (`window.new`). Every method takes
an optional `window` (an id from `window.list`; `0` is the main window) and then runs in that
+1
View File
@@ -172,6 +172,7 @@ Or in a project's `.mcp.json`:
- `create_node` and `set_properties` take `x`/`y` **relative to the parent frame** (the page when
there is none, so they are page coordinates then). Groups and boolean groups have no coordinate
space of their own: a layer in a group is placed in the frame (or on the page) the group is in.
`rotation` is the layer's angle to its parent (Figma's plugin API `rotation` is the same); `absoluteRotation` sets its rotation on the page, what the Design panel's field shows; a call takes one of the two.
`inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates.
In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the
same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call.
+4 -1
View File
File diff suppressed because one or more lines are too long