Merge main into #2191

This commit is contained in:
bflatastic
2026-11-12 12:11:07 +07:00
6 changed files with 105 additions and 8 deletions
+3 -1
View File
@@ -496,7 +496,9 @@ fn collect_flatten_parts(
}
let part = match &node.kind {
NodeKind::Vector { network } => {
let mut part = network.clone();
// Rounded points bake into the path (the arc's end points joined by curves), drawn the way
// the layer draws them, so the shape looks the same and the box fits the real geometry.
let mut part = figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).into_owned();
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
@@ -881,3 +881,68 @@ fn vector_points_take_the_requested_size() {
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
assert!(e.contains("positive"), "{e}");
}
/// A vector from `points` with a corner radius per point (`radii`), via `vector.create`.
fn rounded_vector(s: &mut Session, points: &[(f64, f64)], radii: &[f64], closed: bool) -> NodeId {
let pts: Vec<Value> = points.iter().map(|p| json!([p.0, p.1])).collect();
let id = id_of(&ex(s, "vector.create", json!({"points": pts, "closed": closed}))["id"]).unwrap();
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
for (v, r) in network.vertices.iter_mut().zip(radii) {
v.radius = *r;
}
}
id
}
/// #2195: Flatten bakes a vector's point radii into its path: the rounded vertex becomes the
/// arc's end points joined by curves, the layer stays a vector, the box fits the real geometry.
#[test]
fn flatten_bakes_a_vectors_point_radius() {
// An open V with the apex rounded.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (184.0, 245.0), (368.0, 0.0)], &[0.0, 60.0, 0.0], false);
let before = net(&s, v);
let drawn = before.rounded().unwrap().stroke_path().perimeter(1e-4);
let h_before = s.doc().unwrap().node(v).unwrap().height;
let out = ex(&mut s, "vector.flatten", json!({"ids": [v]}));
let f = id_of(&out["id"]).unwrap();
let after = net(&s, f);
assert_eq!(after.vertices.len(), 4, "the apex became two points: 3 + 1");
assert!(after.vertices.iter().all(|p| p.radius == 0.0), "no radius left");
let len = after.stroke_path().perimeter(1e-4);
assert!((len - drawn).abs() < 0.5, "the outline is the same: {len} vs {drawn}");
let h = s.doc().unwrap().node(f).unwrap().height;
let bb = after.bounds().unwrap();
assert!(h < h_before && (h - 245.0).abs() > 1.0, "the box shrinks from 245: {h}");
assert!((h - bb.height()).abs() < 1e-6 && bb.y0.abs() < 1e-6, "the box is the baked path's bounds: {h} vs {bb:?}");
println!("open V: points {} -> {}, height {h_before} -> {h}", before.vertices.len(), after.vertices.len());
s.undo().unwrap();
assert!(s.doc().unwrap().node(v).is_some() && s.doc().unwrap().node(f).is_none(), "one undo step");
assert_eq!(net(&s, v).vertices[1].radius, 60.0);
// A closed triangle rounded at every point.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (368.0, 0.0), (184.0, 245.0)], &[40.0; 3], true);
let drawn = net(&s, v).rounded().unwrap();
let (len, area) = (drawn.stroke_path().perimeter(1e-4), drawn.fill_path().area().abs());
let f = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [v]}))["id"]).unwrap();
let after = net(&s, f);
assert_eq!(after.vertices.len(), 6, "each corner became two points");
assert!(after.vertices.iter().all(|p| p.radius == 0.0));
assert_eq!(after.regions.len(), 1, "still one filled region");
assert!((after.stroke_path().perimeter(1e-4) - len).abs() < 0.5);
assert!((after.fill_path().area().abs() - area).abs() < 0.5, "same fill area");
let n = s.doc().unwrap().node(f).unwrap().clone();
let bb = after.bounds().unwrap();
assert!(n.height < 245.0 && (n.height - bb.height()).abs() < 1e-6 && (n.width - bb.width()).abs() < 1e-6);
println!("triangle: points 3 -> {}, height 245 -> {}", after.vertices.len(), n.height);
// Guard: no radius, nothing changes.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (184.0, 245.0), (368.0, 0.0)], &[0.0; 3], false);
let before = net(&s, v);
let f = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [v]}))["id"]).unwrap();
assert_eq!(net(&s, f).vertices.len(), 3);
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 245.0);
assert_eq!(net(&s, f).vertices, before.vertices);
}
+4
View File
@@ -22,7 +22,11 @@ impl FigApp {
/// state.
pub fn new(session: Session, services: Box<dyn Services>) -> Self {
// The main window's panels open as wide as they were left.
// Widths saved under other limits are clamped to the current ones, not rejected; one that
// isn't finite falls back to the default.
let (left_width, right_width) = session.prefs.panel_widths;
let within = |w: f32, range: std::ops::RangeInclusive<f32>| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT };
let (left_width, right_width) = (within(left_width, LEFT_PANEL), within(right_width, RIGHT_PANEL));
let pages_height = session.prefs.pages_height;
Self {
ui: UiState { left_width, right_width, pages_height, ..UiState::default() },
+24 -2
View File
@@ -78,7 +78,15 @@ fn panels_fit_the_window() {
((744.0, None, Some(480.0)), (0.0, 480.0), "with the left panel closed the right one has room"),
((300.0, Some(240.0), Some(240.0)), (l0, r0), "a window smaller than any layout: the least widths"),
((1384.0, Some(f32::NAN), Some(f32::INFINITY)), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"),
((744.0, Some(480.0), None), (480.0, 0.0), "the right panel closed (a board's): the left one keeps its width"),
((744.0, Some(480.0), None), (l1, 0.0), "the right panel closed (a board's): the left one keeps as much of its width as it may"),
((744.0, Some(450.0), None), (450.0, 0.0), "the right panel closed: the left one at its most"),
((1384.0, Some(200.0), Some(480.0)), (l0, 480.0), "a stored left width under 240 (the old limit was 200) is brought up to 240"),
((1384.0, Some(470.0), Some(520.0)), (l1, r1), "stored widths over the limits (the old ones were 480) are brought down to 450 and 500"),
(
(744.0, Some(300.0), Some(500.0)),
(240.0, 344.0),
"the smallest window with the right panel at its most: the right gives way last, the left to its 240 floor",
),
];
for ((avail, left, right), want, why) in rows {
assert_eq!(fit_panels(avail, left, right), want, "{why}");
@@ -122,14 +130,17 @@ fn dragging_an_edge_sizes_its_panel_within_its_limits_and_keeps_the_views_centre
let (left, right) = edges(&u);
drag(&mut u, pos2(left, 300.0), pos2(left + 700.0, 300.0));
assert_eq!(widths(&u).0, *LEFT_PANEL.end());
assert_eq!(widths(&u).0, 450.0, "Figma's left sidebar stops at 450");
let (left, _) = edges(&u);
drag(&mut u, pos2(left, 300.0), pos2(20.0, 300.0));
assert_eq!(widths(&u).0, *LEFT_PANEL.start());
assert_eq!(widths(&u).0, 240.0, "and at 240 the other way");
drag(&mut u, pos2(right, 300.0), pos2(right + 500.0, 300.0));
assert_eq!(widths(&u).1, *RIGHT_PANEL.start());
let (_, right) = edges(&u);
u.drag_to(pos2(right, 300.0), pos2(right - 900.0, 300.0));
assert_eq!(widths(&u).1, *RIGHT_PANEL.end(), "at its most while the pointer is past it");
assert_eq!(widths(&u).1, 500.0, "Figma's right sidebar stops at 500");
u.frame(vec![Event::PointerMoved(pos2(right - 100.0, 300.0))]);
assert_eq!(widths(&u).1, *RIGHT_PANEL.start() + 100.0, "back with the pointer");
u.button(pos2(right - 100.0, 300.0), false);
@@ -187,6 +198,17 @@ fn the_widths_come_back_with_the_preferences() {
u.frame(vec![]);
let (left, right) = edges(&u);
assert_eq!((left, 1440.0 - right), (56.0 + *LEFT_PANEL.start(), PANEL_DEFAULT));
// Widths saved under the old limits (left 200 to 480, right up to 480) or over the new ones
// are clamped when loaded, not rejected.
for (stored, want) in
[((200.0, 240.0), (240.0, 240.0)), ((239.0, 500.0), (240.0, 500.0)), ((451.0, 480.0), (450.0, 480.0)), ((480.0, 600.0), (450.0, 500.0))]
{
let mut s = Session::new();
s.prefs.panel_widths = stored;
let mut u = Ui::new(s);
u.frame(vec![]);
assert_eq!(widths(&u), want, "stored {stored:?}");
}
}
#[test]
@@ -264,7 +286,7 @@ fn minimize_collapses_the_rail_and_both_sidebars_and_the_right_one_follows_the_s
#[test]
fn the_smallest_window_keeps_its_canvas_whatever_the_widths() {
for asked in [(240.0, 240.0), (480.0, 480.0), (480.0, 240.0), (200.0, 480.0)] {
for asked in [(240.0, 240.0), (450.0, 500.0), (450.0, 240.0), (240.0, 500.0)] {
let mut s = Session::new();
s.prefs.panel_widths = asked;
let ctx = egui::Context::default();
+4 -3
View File
@@ -27,10 +27,11 @@ pub enum TitleBar {
pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result<Vec<u8>>)>;
/// The side panels' widths: the least and most a drag of their edge gives, what a double-click on
/// the edge puts back, and how much of the window they leave the canvas.
pub const LEFT_PANEL: std::ops::RangeInclusive<f32> = 200.0..=480.0;
/// the edge puts back, and how much of the window they leave the canvas. The limits are Figma's,
/// measured in the web app: left 240 to 450, right 240 to 500, both 240 by default.
pub const LEFT_PANEL: std::ops::RangeInclusive<f32> = 240.0..=450.0;
pub const RIGHT_PANEL: std::ops::RangeInclusive<f32> = 240.0..=480.0;
pub const RIGHT_PANEL: std::ops::RangeInclusive<f32> = 240.0..=500.0;
pub const PANEL_DEFAULT: f32 = 240.0;
+5 -2
View File
File diff suppressed because one or more lines are too long