Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 09:00:12 +07:00
22 changed files with 686 additions and 35 deletions
+10 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89)
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89)
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
from scratch in Rust. This page is the one-page summary; the evidence is in
@@ -117,6 +117,14 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel,
Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items
drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one
animation preset can hold several (#2552). The Composition panel pans while a mask vertex is
held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget
(#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick
between frames (#2593). ScriptUI drags keep their control past the window edge with
`MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585).
- 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).
@@ -169,6 +177,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size |
| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
+43 -20
View File
@@ -5,6 +5,7 @@
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::layer_menu::target_masks;
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
/// path is the shape that was on screen rather than an approximation of it; the defaults match
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
/// roundness).
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"rect" => {
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
if ctx.b(layer, g, "perCorner") {
effectcraft_path::rect_radii(
size,
pos,
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
)
} else {
effectcraft_path::rect(size, pos, f("roundness", 0.0))
}
}
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
star: ctx.e(layer, g, "type") == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
}
let t = s.time();
let n = shapes.len();
// Read the geometry before editing, with the same expression-aware context the renderer
// uses: an expression-driven corner radius, size or position must convert to what is on
// screen, not to the property's underlying value.
let prepared = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut prepared = Vec::new();
for (lid, uid) in &shapes {
let Some(l) = comp.layer(*lid) else { continue };
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
let Some(path) = parametric_path(&ctx, l, old) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
}
prepared
};
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
let mut next = proj.next_id;
let mut made = Vec::new();
for (lid, uid) in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
for (lid, uid, path, direction, enabled) in prepared {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
let Some(old) = contents.find_group(*uid) else { continue };
let Some(path) = parametric_path(old, lt) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
let enabled = old.enabled;
let mut g = build::shape_path(&mut Ids(&mut next), path);
if let Some(pr) = g.prop_mut("direction") {
pr.value = KV::Enum(direction);
}
g.enabled = enabled;
let new_uid = g.uid;
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
parent.children[at] = Node::Group(g);
made.push((*lid, new_uid));
made.push((lid, new_uid));
}
proj.next_id = next;
// The new paths are what is selected, so the vertices can be edited straight away.
+9
View File
@@ -581,6 +581,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
scriptui::mouse
),
cmd!(
"scriptui.resize",
"Resize Script Window",
[],
None,
"{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)",
always,
scriptui::resize
),
cmd!(
"scriptui.close",
"Close Script Window",
+11
View File
@@ -769,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
}
/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the
/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`.
pub(crate) fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.resize";
let window = window_p(s, p, c)?;
let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?;
let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0));
let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) };
dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) })
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;
+64
View File
@@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
}
/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so
/// the editable path is the shape that was on screen.
#[test]
fn convert_to_bezier_honours_per_corner_roundness() {
let mut s = comp();
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
let per_corner = uid_of(&s, "perCorner");
let top_left = uid_of(&s, "topLeftRoundness");
let bottom_right = uid_of(&s, "bottomRightRoundness");
for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] {
s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the converted path has the per-corner geometry");
}
/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven
/// Per-Corner checkbox and radius convert to the geometry on screen.
#[test]
fn convert_to_bezier_evaluates_corner_expressions() {
struct Corners;
impl effectcraft_render::ExprHost for Corners {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
prop: &effectcraft_project::Property,
value: &KV,
) -> std::result::Result<KV, String> {
Ok(match prop.match_id.as_str() {
"perCorner" => KV::Bool(true),
"topLeftRoundness" => KV::Scalar(20.0),
_ => value.clone(),
})
}
}
let mut s = comp();
s.expr = Some(Arc::new(Corners));
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] {
let u = uid_of(&s, id);
s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the expression-evaluated corner is converted");
}
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
/// shared merge key; one undo must revert the whole drag, not the last frame.
#[test]
+87 -1
View File
@@ -431,12 +431,13 @@ impl<'a> Cx<'a> {
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
// frame_size applies crop itself: use the original frame and the same scale as edges.
let (w, h) = om.frame_size(img.width, img.height, scale);
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
if !om.resize.enabled {
return img;
}
let (w, h) = om.frame_size(img.width, img.height, 1.0);
if (w, h) == (img.width, img.height) {
return img;
}
@@ -1062,6 +1063,91 @@ mod tests {
}
}
/// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440.
#[test]
fn crop_resize_applies_margins_once() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
let output = OutputModule {
crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() },
resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() },
..Default::default()
};
assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440));
let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0);
assert_eq!((img.width, img.height), (1280, 1440));
assert_eq!(img.data.len(), 1280 * 1440);
}
/// Apply output-module geometry to a synthetic frame without rendering or writing files.
fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image {
use effectcraft_project::render_queue::RenderSettings;
let project = Project::default();
let settings = RenderSettings { resolution: scale, ..Default::default() };
let job = Job {
project: &project,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: None,
comp: effectcraft_project::ItemId(0),
settings: &settings,
output,
path: "unused.png",
sink: None,
nested_switches: true,
options: Default::default(),
};
Cx::new(&job).crop_resize(img)
}
/// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities.
#[test]
fn crop_resize_dimensions_match_the_output_module() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() };
let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() };
let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() };
for quality in [ResizeQuality::Low, ResizeQuality::High] {
let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality };
for (crop, resize, scale, expected) in [
(Crop::default(), Resize::default(), 1.0, (8, 6)),
(edges, Resize::default(), 1.0, (4, 6)),
(Crop::default(), resize(4), 1.0, (4, 3)),
(edges, resize(8), 1.0, (8, 12)),
(padding, resize(5), 1.0, (5, 4)),
(roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)),
(roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)),
(edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)),
] {
let output = OutputModule { crop, resize, ..Default::default() };
assert_eq!(output.frame_size(8, 6, scale), expected);
let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale);
assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}");
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
}
}
}
/// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes.
#[test]
fn crop_resize_zero_and_extreme_sizes_stay_bounded() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
for quality in [ResizeQuality::Low, ResizeQuality::High] {
for (size, crop, width, height, expected) in [
((8, 6), Crop::default(), 0, 0, (1, 1)),
((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)),
((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)),
((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)),
((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)),
] {
let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() };
let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0);
assert_eq!((img.width, img.height), expected);
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
assert!(img.data.iter().all(|p| *p == [0.0; 4]));
}
}
}
#[test]
fn crop_and_pad() {
let mut img = Image::new(4, 3);
+25
View File
@@ -174,6 +174,31 @@ fn crop_roi_and_resize() {
assert_eq!((z.width(), z.height()), (32, 24));
}
/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel.
/// Compare against crop-only output for padding and scaled ROIs with both resize qualities.
#[test]
fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
for (crop, scale, expected) in [
(Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)),
(Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)),
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)),
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)),
] {
let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) };
let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) };
let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0);
assert_eq!(reference.dimensions(), expected);
assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content");
for quality in [ResizeQuality::Low, ResizeQuality::High] {
output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality };
let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0);
assert_eq!(actual.dimensions(), expected);
assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed");
}
}
}
#[test]
fn alpha_only_and_premultiplied() {
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
+22 -1
View File
@@ -113,6 +113,9 @@ impl Out<'_, '_> {
fn f(&self, m: &str) -> f64 {
self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0)
}
fn flag(&self, m: &str) -> bool {
self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false)
}
fn animated(&self, m: &str) -> bool {
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
}
@@ -232,7 +235,20 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve
dir(&mut o);
o.put("s", "size", &anim::arr2);
o.put("p", "position", &anim::arr2);
o.put("r", "roundness", &anim::num);
if o.flag("perCorner") {
let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"];
let radii = names.map(|m| o.f(m));
// Lottie `rc` has one `r`: four equal static radii export as that value.
// `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number.
if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 {
o.raw("r", json!({"a": 0, "k": r(radii[0])}));
} else {
o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label));
o.raw("r", json!({"a": 0, "k": 0}));
}
} else {
o.put("r", "roundness", &anim::num);
}
}
"el" => {
dir(&mut o);
@@ -447,6 +463,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
// Lottie has one roundness: keep the per-corner values in step so ticking the box
// on an imported rectangle keeps the shape.
for m in ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"] {
set(im, &mut g, m, j.get("r"), &anim::to_scalar);
}
g
}
"el" => {
+36
View File
@@ -896,3 +896,39 @@ fn muted_keys_are_omitted_from_the_export() {
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
}
#[test]
fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() {
let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| {
let (mut p, cid) = setup();
let l = shape_layer(&mut p, cid, move |ids| {
let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness);
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
for (m, v) in
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
{
g.prop_mut(m).unwrap().value = Value::Scalar(v);
}
let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])]
});
push(&mut p, cid, l);
(p, cid)
};
let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() };
// Four equal static radii: `rc.r` carries the value, no warning.
let (p, cid) = shape([12.0; 4], 5.0, true);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0}));
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
// Mixed radii: not representable, roundness 0 exported with a warning.
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0}));
assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}");
// The checkbox off exports the single Roundness and ignores the four values.
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"]["k"], json!(5.0));
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
}
+77
View File
@@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
p
}
/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right,
/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii
/// are 0; radii that would overlap are scaled down as CSS `border-radius` does.
pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath {
// Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners
// clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must
// not make a trim jump (#1896).
let w = if size[0].is_finite() { size[0].abs() } else { 0.0 };
let h = if size[1].is_finite() { size[1].abs() } else { 0.0 };
let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0);
let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0);
let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
// CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest
// edge ratio (a uniform radius then clamps to half the shorter side, as before).
let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 };
let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h));
if f < 1.0 {
for v in &mut r {
*v *= f;
}
}
let [tl, tr, br, bl] = r;
// Quarter-circle Bezier control distance.
const K: f64 = 0.552_284_749_830_793_6;
let mut p = BezPath::new();
p.move_to((x1, y0 + tr));
p.line_to((x1, y1 - br));
if br > 0.0 {
p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1));
}
p.line_to((x0 + bl, y1));
if bl > 0.0 {
p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl));
}
p.line_to((x0, y0 + tl));
if tl > 0.0 {
p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0));
}
p.line_to((x1 - tr, y0));
if tr > 0.0 {
p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr));
}
p.close_path();
p
}
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
to_kurbo(&ShapePath::ellipse(pos, size[0], size[1]))
}
@@ -419,6 +465,11 @@ mod tests {
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&round[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
// Per-corner roundness starts and runs in the same order: ticking the checkbox must not
// move a Trim Paths contour.
let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0);
let (a, b) = ends(&corners[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}");
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
let (a, b) = ends(&swapped[0]);
@@ -456,6 +507,32 @@ mod tests {
assert!((area - 200.0).abs() < 1.0, "{area}");
}
#[test]
fn rect_radii_uniform_matches_a_rounded_rect() {
for r in [0.0, 5.0, 25.0, 100.0] {
let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]);
let clamped = r.min(25.0);
let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped;
assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area());
assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6);
}
}
#[test]
fn rect_radii_mixes_corners_and_scales_overlaps() {
// Only the top-left and bottom-right corners round.
let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]);
let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0);
assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area());
// Radii wider than the top edge scale down together, like CSS border-radius.
let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]);
let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]);
assert!((p.area() - scaled.area()).abs() < 1e-9);
// Negative and non-finite radii are square corners.
let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]);
assert!((p.area().abs() - 2500.0).abs() < 1e-9);
}
#[test]
fn stroke_has_area() {
let r = rect([40.0, 40.0], [50.0, 50.0], 0.0);
+7
View File
@@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec<PropGroup>) -> PropGrou
}
pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup {
// Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape.
let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 };
indexed(ids.group("rect", "Rectangle Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels))
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
.with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels))
.with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
}
pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup {
+14 -1
View File
@@ -269,7 +269,20 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
let p = if ctx.b(layer, g, "perCorner") {
effectcraft_path::rect_radii(
size,
pos,
[
ctx.f(layer, g, "topLeftRoundness", 0.0),
ctx.f(layer, g, "topRightRoundness", 0.0),
ctx.f(layer, g, "bottomRightRoundness", 0.0),
ctx.f(layer, g, "bottomLeftRoundness", 0.0),
],
)
} else {
effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0))
};
push_shape(ctx, layer, g, p, arena, &mut live);
}
"ellipse" => {
+30
View File
@@ -715,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() {
let b = shape_buf(&p, cid);
assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height);
}
#[test]
fn rect_per_corner_roundness_rounds_the_named_corners() {
let render = |per_corner: bool, radii: [f64; 4], roundness: f64| {
let (p, cid) = shape_comp_sized(200, 100, move |ids| {
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness);
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
for (m, v) in
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
{
g.prop_mut(m).unwrap().value = Value::Scalar(v);
}
vec![g, build::shape_fill(ids, RED)]
});
frame(&p, cid)
};
// Only the top-left corner rounds: its corner pixels fade, the other three stay filled.
let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0);
assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2));
for (x, y) in [(148, 2), (148, 98), (52, 98)] {
assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y));
}
// The checkbox off ignores the four values (the single Roundness still rules).
let off = render(false, [30.0; 4], 0.0);
assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off));
// Four equal per-corner radii render like the same Roundness.
let per = render(true, [25.0; 4], 0.0);
let uni = render(false, [0.0; 4], 25.0);
assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni));
}
+12
View File
@@ -912,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) {
w.close(v === null ? 2 : v);
return;
}
// The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script.
if (kind === "resize") {
var nw = Number(v && v[0]), nh = Number(v && v[1]);
var b = w.__ub || w.__lb;
if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) {
w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh];
__uiLayout(w);
w.__fire("onResizing");
w.__fire("onResize");
}
return;
}
var c = __uiFind(w, id);
if (!c || c.enabled === false) return;
var radio = function () {
@@ -26,6 +26,8 @@ enum Act {
Close(u32),
/// A mouse event (`scriptui.mouse` parameters without window and widget).
Mouse(u32, u32, Value),
/// A docked panel's new size (`scriptui.resize`).
Resize(u32, [f32; 2]),
}
fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect {
@@ -500,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})),
Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})),
Act::Close(w) => ("scriptui.close", json!({"window": w})),
Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})),
Act::Mouse(w, id, mut v) => {
if let Some(o) = v.as_object_mut() {
o.insert("window".into(), json!(w));
@@ -582,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) {
// A modal script dialog blocks the panels too.
let modal = app.session.script_ui.windows.iter().any(|w| w.modal);
let mut acts = vec![];
// The script learns its dock's size when it changes (#2585), as After Effects tells a
// docked panel: its window takes the size and onResize runs.
let dock = [rect.width().round(), rect.height().round()];
let told = egui::Id::unique(("scriptui-dock-size", id));
if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 {
ui.data_mut(|d| d.insert_temp(told, dock));
acts.push(Act::Resize(id, dock));
}
let content = rect;
egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover());
+9 -4
View File
@@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate());
let r = match kind {
Kind::Int(lo, hi, unit) => {
let mut x = v.as_i64().unwrap_or(0);
// A typed number applies once entered: typing 85 into UI Scale
// doesn't resize the interface to 75 % at its first digit.
// A dragged value applies on release, and a typed one once
// entered: UI Scale doesn't resize the interface under the
// pointer mid-drag (#2582) or at a typed number's first digit.
let held = egui::Id::unique(("settings-drag", key));
let mut x = ui.data(|d| d.get_temp::<i64>(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0));
let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
let r = ui.add(crate::widgets::drag_value(drag));
if !unit.is_empty() {
ui.label(RichText::new(tr(unit)).color(t.text_dim));
}
if r.changed() {
if r.dragged() {
ui.data_mut(|d| d.insert_temp(held, x));
} else if r.changed() || r.drag_stopped() {
ui.data_mut(|d| d.remove::<i64>(held));
acts.push(Act::Set(key.into(), json!(x)));
}
r
@@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() {
" Size",
" Position",
" Roundness",
" Per-Corner Roundness",
" Top Left Roundness",
" Top Right Roundness",
" Bottom Right Roundness",
" Bottom Left Roundness",
" >Fill 1",
" Composite",
" Fill Rule",
+146 -6
View File
@@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point
/// repeated for a closed path.
pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
let k = effectcraft_engine::render::kurbo_path(sp);
let mut k = effectcraft_engine::render::kurbo_path(sp);
// A path-space tolerance becomes visible corners when the viewer or a layer/group is
// magnified. Flatten affine paths in screen space so the error stays below half a point.
// Projective transforms must still map the flattened points: projecting Bezier control
// points would change the curve rather than just its tessellation.
let screen_space = m.is_affine();
if screen_space {
let z = f64::from(map.zoom);
k.apply_affine(kurbo::Affine::new([
m.0[0][0] * z,
m.0[1][0] * z,
m.0[0][1] * z,
m.0[1][1] * z,
m.0[0][2] * z + f64::from(map.origin.x),
m.0[1][2] * z + f64::from(map.origin.y),
]));
}
if !k.is_finite() {
return Vec::new();
}
let bounds = k.control_box();
if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) {
return Vec::new();
}
// Bound subdivision work even for extreme finite scales from a malformed project. For
// ordinary on-screen paths the tolerance remains half a point.
let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5);
let mut pts: Vec<Pos2> = vec![];
kurbo::flatten(k.iter(), 0.5, |el| match el {
kurbo::flatten(k.iter(), tolerance, |el| match el {
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
let c = m.apply(gv2(q.x, q.y));
pts.push(map.to_screen([c.x, c.y]));
let p = if screen_space {
egui::pos2(q.x as f32, q.y as f32)
} else {
let c = m.apply(gv2(q.x, q.y));
map.to_screen([c.x, c.y])
};
if p.is_finite() {
pts.push(p);
}
}
_ => {}
});
if sp.closed && !pts.is_empty() {
pts.push(pts[0]);
if sp.closed
&& let Some(first) = pts.first().copied()
{
pts.push(first);
}
pts
}
@@ -685,6 +720,111 @@ mod tests {
PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() }
}
fn circle() -> ShapePath {
let r = 38.0;
let h = r * 0.552_284_749_830_793_6;
ShapePath {
vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]],
in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]],
out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]],
closed: true,
feather: Vec::new(),
}
}
/// Measure the actual screen-space error against dense samples of the original cubics,
/// independently of the tessellator's chosen subdivisions.
fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 {
let mut worst = 0.0_f32;
for i in 0..sp.vertices.len() {
let Some(c) = segment(sp, i) else { continue };
for step in 0..=1024 {
let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0);
let q = m.apply(gv2(q[0], q[1]));
let p = map.to_screen([q.x, q.y]);
let distance = points
.windows(2)
.map(|pair| {
let a = pair[0];
let v = pair[1] - a;
let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
p.distance(a + v * t)
})
.fold(f32::INFINITY, f32::min);
worst = worst.max(distance);
}
}
worst
}
#[test]
fn curve_overlay_stays_smooth_at_viewer_magnifications() {
let sp = circle();
let mut counts = Vec::new();
for zoom in [0.5, 1.0, 5.0] {
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
let points = flatten(&sp, &Mat3::IDENTITY, &map);
let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points);
assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve");
assert_eq!(points.first(), points.last(), "closed paths keep their closing edge");
for v in &sp.vertices {
let anchor = map.to_screen(*v);
assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x");
}
counts.push(points.len());
}
assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}");
}
#[test]
fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() {
let sp = circle();
let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8));
let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5));
let m = layer * group;
for zoom in [0.5, 1.0, 5.0] {
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
let points = flatten(&sp, &m, &map);
let error = screen_error(&sp, &m, &map, &points);
assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve");
}
}
#[test]
fn open_overlay_preserves_its_transformed_endpoints() {
let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp;
let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8));
let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
let points = flatten(&sp, &m, &map);
assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge");
for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) {
let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1]));
assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001);
}
}
#[test]
fn invalid_overlay_coordinates_are_not_tessellated() {
let sp = circle();
for zoom in [f32::NAN, f32::INFINITY] {
let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING };
assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty());
}
let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty());
}
#[test]
fn extreme_finite_overlay_scales_keep_subdivision_bounded() {
let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING };
let sp = circle();
let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map);
assert!(!points.is_empty());
assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices");
assert!(points.iter().all(|p| p.is_finite()));
assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty());
}
/// #912: clicking the last vertex of an open path with the Pen resumes that path.
#[test]
fn pen_resumes_an_open_path_at_its_last_vertex() {
+30
View File
@@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() {
assert_eq!(fx.name, "Radial Wipe");
assert_eq!(fx.prop("completion").unwrap().keys.len(), 2);
}
/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value
/// applies once the button is released.
#[test]
fn ui_scale_applies_when_the_drag_ends() {
let mut h = harness();
open_settings(&mut h);
let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field");
let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for k in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0)));
h.step();
assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag");
}
h.input_mut().events.push(Event::PointerButton {
pos: from + egui::vec2(80.0, 0.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
});
h.run_steps(4);
let (pct, zoom) = scale(&h);
assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}");
click(&mut h, "settings.cancel");
h.run_steps(2);
assert_eq!(scale(&h), (100, 1.0));
}
+32
View File
@@ -341,6 +341,38 @@ fn a_drag_keeps_its_control_past_the_window_edge() {
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
}
/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked
/// ScriptUI panel gets its dock's size that way.
#[test]
fn script_windows_learn_the_size_they_are_given() {
let mut h = harness();
let code = r#"
var w = new Window("palette", "Sized");
var log = w.add("statictext", undefined, "", { name: "log" });
log.preferredSize = [200, 20];
w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; };
w.show();
"#;
let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap();
assert_eq!(r["ok"], true, "{r}");
h.run_steps(2);
h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap();
let win = h.state().session.script_ui.windows[0].clone();
assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180");
let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap();
assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}");
assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err());
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
// A docked panel: its window is its dock's size, not its preferred size.
h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
h.run_steps(4);
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone();
let dock = rect(&h, &format!("scriptui.{}", panel.id));
assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]);
h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id));
h.run_steps(2);
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) {
h.input_mut().events.push(Event::PointerMoved(c));
h.run_steps(1);
@@ -340,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() {
let fill = fill.expect("a fill colour");
fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec<u64>) {
for c in g.groups() {
// The rectangle's path rows would push Fill below the rows the Timeline fits; the
// eyedropper only needs Fill, so leave that group collapsed.
if c.name == "Rectangle Path 1" {
continue;
}
out.push(c.uid);
groups(c, out);
}
+1 -1
View File
@@ -20,7 +20,7 @@ our own scripts; no real-world script library has been run against both apps.
| Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark |
| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) |
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |