mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
feat(ui): Rectangle and Ellipse Size get the Constrain Proportions chain (#2489)
Resizing a rectangle or ellipse while keeping its proportions (a circle that stays a circle, a 16:9 card that stays 16:9) needed W and H typed or keyed separately. The Timeline's Size row of a shape's Rectangle Path and Ellipse Path now has the chain link that Scale and Mask Feather have. It starts off; turned on, typing or scrubbing W or H keeps the ratio (Alt still edits one value). Scale and Mask Feather keep their linked-by-default state in `unlinked`; Size, which starts unlinked, records a turned-on link in a new `linked` set, so neither default changes the other's saved state.
This commit is contained in:
4 files changed
+74
-6
No files matched your search
@@ -117,6 +117,9 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
|
||||
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
|
||||
starts off, so existing shapes behave as before (#2489).
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
|
||||
@@ -4401,15 +4401,17 @@ fn value_editor(
|
||||
let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
|
||||
let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
|
||||
// Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
|
||||
let linkable = feather || (pct && prop.match_id == "scale");
|
||||
let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
|
||||
// Rectangle and Ellipse Path Size have it too, off by default (#2489).
|
||||
let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse"));
|
||||
let linkable = feather || size || (pct && prop.match_id == "scale");
|
||||
let linked = linkable && app.ui.timeline.is_linked(uid, !size);
|
||||
if linkable {
|
||||
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
|
||||
let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
|
||||
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
|
||||
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
|
||||
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
|
||||
app.ui.timeline.unlinked.insert(uid);
|
||||
if resp.clicked() {
|
||||
app.ui.timeline.toggle_link(uid, !size);
|
||||
}
|
||||
x += 18.0;
|
||||
}
|
||||
|
||||
@@ -307,6 +307,21 @@ impl Default for ViewerState {
|
||||
}
|
||||
|
||||
impl TimelineState {
|
||||
/// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked
|
||||
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size)
|
||||
/// records it in `linked`.
|
||||
pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool {
|
||||
if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) }
|
||||
}
|
||||
|
||||
/// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]).
|
||||
pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) {
|
||||
let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked };
|
||||
if !set.remove(&uid) {
|
||||
set.insert(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last
|
||||
/// reveal. Other layers keep theirs.
|
||||
pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec<String>) {
|
||||
@@ -414,6 +429,10 @@ pub struct TimelineState {
|
||||
/// was turned off.
|
||||
#[serde(default)]
|
||||
pub unlinked: BTreeSet<u64>,
|
||||
/// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was
|
||||
/// turned on (#2489).
|
||||
#[serde(default)]
|
||||
pub linked: BTreeSet<u64>,
|
||||
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
|
||||
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
|
||||
/// column is always shown; Modes follows `show_modes` (F4).
|
||||
@@ -466,6 +485,7 @@ impl Default for TimelineState {
|
||||
expr_closed: BTreeSet::new(),
|
||||
expr_lines: BTreeMap::new(),
|
||||
unlinked: BTreeSet::new(),
|
||||
linked: BTreeSet::new(),
|
||||
columns: default_tl_columns(),
|
||||
source_name: false,
|
||||
scroll_to: None,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic
|
||||
//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the
|
||||
//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items
|
||||
//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738).
|
||||
//! layers deselecting them (#205), Mask Feather's linked values (#203) and shape Size's chain link
|
||||
//! (#2489), copying shape items between shape layers (#227), and Ctrl+V of SVG markup making a shape
|
||||
//! layer (#738).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
@@ -172,6 +173,48 @@ fn mask_feather_values_are_linked_and_not_negative() {
|
||||
assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked");
|
||||
}
|
||||
|
||||
/// #2489: Rectangle and Ellipse Path Size get the Constrain Proportions chain, off by default:
|
||||
/// unlinked, W moves alone; linked, H follows W in proportion. Scale on the same layer still starts
|
||||
/// linked.
|
||||
#[test]
|
||||
fn shape_size_chain_starts_unlinked_and_keeps_the_ratio_when_linked() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [100, 50]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let o = s.execute("layer.newShape", json!({"kind": "ellipse", "size": [60, 30]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let uid = |s: &Session, l: u64, path: &str| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().uid;
|
||||
let (size, oval, scale) =
|
||||
(uid(&s, l, "contents/group/contents/rect/size"), uid(&s, o, "contents/group/contents/ellipse/size"), uid(&s, l, "transform/scale"));
|
||||
let mut h = harness(s);
|
||||
for (layer, props) in [(l, vec![size, scale]), (o, vec![oval])] {
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["props".into()]);
|
||||
h.state_mut().ui.timeline.reveal_props.extend(props);
|
||||
}
|
||||
h.run_steps(2);
|
||||
let value = |h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(&format!("@{uid}")).unwrap().value.components()
|
||||
};
|
||||
let type_value = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, d: usize, v: &str| {
|
||||
click(h, &format!("timeline.prop.{uid}.value.{d}"));
|
||||
h.input_mut().events.push(Event::Text(v.into()));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
};
|
||||
type_value(&mut h, size, 0, "80");
|
||||
assert_eq!(value(&h, l, size), vec![80.0, 50.0], "a new rectangle's Size starts unlinked");
|
||||
type_value(&mut h, oval, 1, "40");
|
||||
assert_eq!(value(&h, o, oval), vec![60.0, 40.0], "a new ellipse's Size starts unlinked");
|
||||
click(&mut h, &format!("timeline.prop.{size}.link"));
|
||||
type_value(&mut h, size, 0, "160");
|
||||
assert_eq!(value(&h, l, size), vec![160.0, 100.0], "linked, H follows W in proportion");
|
||||
click(&mut h, &format!("timeline.prop.{size}.link"));
|
||||
type_value(&mut h, size, 0, "40");
|
||||
assert_eq!(value(&h, l, size), vec![40.0, 100.0], "unlinked again, H stays");
|
||||
type_value(&mut h, scale, 0, "50");
|
||||
assert_eq!(value(&h, l, scale)[..2], [50.0, 50.0], "Scale still starts linked");
|
||||
}
|
||||
|
||||
/// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's
|
||||
/// Contents open, Rect, Oval, Rectangle 1's uid).
|
||||
fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) {
|
||||
|
||||
Reference in new issue
Block a user