Merge main into #2192

This commit is contained in:
bflatastic committed 2026-11-12 13:14:43 +07:00
commit 23db6dbddb
24 files changed
+938 -46

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
//! A boolean group's corner radius through the engine: it rounds the result (and the SVG export
//! of it), children keep their own radius, Flatten bakes the arcs and clicks follow the result.
use serde_json::json;
use crate::Session;
use crate::booleans_tests::{area, click, ex};
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::{Point, Shape};
const L_AREA: f64 = 34656.6;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap()
}
fn snapping_off(s: &mut Session) {
ex(s, "select.none", json!({}));
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
}
fn set_radius(s: &mut Session, id: NodeId, r: f64) {
ex(s, "node.set", json!({"ids": [id], "props": {"cornerRadius": r}}));
}
/// The L of two rectangles, united, nothing selected, snapping off.
fn l_group(s: &mut Session) -> NodeId {
let (a, b) = (rect(s, 0.0, 0.0, 200.0, 100.0), rect(s, 150.0, 0.0, 100.0, 200.0));
let g = id_of(&ex(s, "bool.union", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
g
}
/// Whether the page point is inside the group's drawn result.
fn covers(s: &Session, g: NodeId, x: f64, y: f64) -> bool {
let doc = s.doc().unwrap();
let (path, _) = figcraft_render::node_outline(doc, doc.node(g).unwrap()).unwrap();
let p = doc.world_transform(g).inverse() * Point::new(x, y);
path.winding(p) % 2 != 0
}
#[test]
fn a_boolean_group_rounds_every_sharp_corner_of_its_result() {
let mut s = Session::new();
let g = l_group(&mut s);
assert!(covers(&s, g, 1.0, 1.0));
set_radius(&mut s, g, 20.0);
assert!(!covers(&s, g, 1.0, 1.0) && covers(&s, g, 148.0, 102.0));
assert!((area(&s, g) - L_AREA).abs() < 1.0, "{}", area(&s, g));
let doc = s.doc().unwrap();
let svg = figcraft_svg::export_svg(doc, &[g], &figcraft_svg::SvgOptions::default()).unwrap();
let d = svg.split("d=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let exported = figcraft_geom::svg_path(d).unwrap();
assert!((exported.area().abs() - L_AREA).abs() < 1.0, "{}", exported.area());
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii, [0.0; 4], "one undo step");
assert!((area(&s, g) - 35000.0).abs() < 1.0);
}
/// The notch of the measurement: A (200 by 100 at the origin) minus a 100 square on its top edge,
/// whose own radius is `a_radius`, under a group radius of `radius`.
fn notch(s: &mut Session, a: NodeId, radius: f64) -> NodeId {
let b = rect(s, 50.0, -50.0, 100.0, 100.0);
let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
set_radius(s, g, radius);
g
}
/// The asserts of the measurement on a notched group at radius 20 under A's own 10.
fn assert_notch_at_20(s: &Session, g: NodeId) {
assert!(covers(s, g, 4.0, 4.0), "A's 10 was not enlarged to 20");
assert!(!covers(s, g, 48.0, 2.0), "the cut's top corner rounds");
assert!(covers(s, g, 52.0, 48.0), "the cut's inner corner fills in");
}
#[test]
fn children_keep_their_radius_and_a_subtraction_rounds_its_cut() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
set_radius(&mut s, a, 10.0);
let g = notch(&mut s, a, 20.0);
assert_notch_at_20(&s, g);
assert_eq!(s.doc().unwrap().node(a).unwrap().corners.radii, [10.0; 4]);
set_radius(&mut s, g, 500.0);
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii[0], 500.0, "the field keeps the value");
assert!(!covers(&s, g, 40.0, 4.0), "25, not the 20 that half the 40-long edge would give");
assert!(covers(&s, g, 27.0, 1.0), "and not more than 25");
assert!(covers(&s, g, 100.0, 51.0) && !covers(&s, g, 100.0, 49.0), "the floor stays straight between 75 and 125");
assert!(covers(&s, g, 4.0, 4.0));
}
#[test]
fn a_nested_group_keeps_its_own_radius() {
let mut s = Session::new();
let (l, r) = (rect(&mut s, 0.0, 0.0, 120.0, 100.0), rect(&mut s, 80.0, 0.0, 120.0, 100.0));
let inner = id_of(&ex(&mut s, "bool.union", json!({"ids": [l, r]}))["id"]).unwrap();
set_radius(&mut s, inner, 10.0);
let g = notch(&mut s, inner, 20.0);
assert_notch_at_20(&s, g);
}
#[test]
fn flatten_bakes_the_arcs_and_the_vector_has_no_radius() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
let flat = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [g]}))["id"]).unwrap();
let doc = s.doc().unwrap();
let n = doc.node(flat).unwrap();
let NodeKind::Vector { network } = &n.kind else { panic!("a vector: {:?}", n.kind) };
assert_eq!(n.corners.radii, [0.0; 4]);
assert!(network.vertices.iter().all(|v| v.radius == 0.0));
let path = figcraft_doc::geometry::shape_path(n).unwrap();
assert!((path.area().abs() - L_AREA).abs() < 1.0, "{}", path.area());
assert!((n.width - 250.0).abs() < 1e-6 && (n.height - 200.0).abs() < 1e-6);
}
#[test]
fn clicks_follow_the_rounded_result() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
click(&mut s, 1.0, 1.0, false);
assert!(s.selection().is_empty(), "the cut-off corner reaches what is behind");
click(&mut s, 148.0, 102.0, false);
assert_eq!(s.selection(), vec![g], "the concave fillet belongs to the group");
ex(&mut s, "select.none", json!({}));
click(&mut s, 100.0, 50.0, false);
assert_eq!(s.selection(), vec![g]);
}
+4 -4
View File
@@ -11,21 +11,21 @@ use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{BoolOp, NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
pub(crate) fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
pub(crate) fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
/// A layer's filled area (a boolean group's result; none of these have holes).
fn area(s: &Session, id: NodeId) -> f64 {
pub(crate) fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs()
}
+2
View File
@@ -227,6 +227,8 @@ mod board_tools_tests;
#[cfg(test)]
mod boolean_cap_tests;
#[cfg(test)]
mod boolean_radius_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
@@ -189,6 +189,7 @@ fn corner_radius_follows_the_documented_rules() {
(NodeKind::Polygon { count: 5 }, true, false, "polygons: one radius for every corner"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, false, "stars: one radius for every corner"),
(NodeKind::Vector { network: Default::default() }, true, false, "vectors: a radius, and one per point in edit mode"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, false, "boolean groups: one radius for every sharp corner of the result"),
(NodeKind::Line, false, false, "lines and arrows take no radius"),
];
for (kind, radius, independent, why) in doc_rows {
@@ -241,6 +242,7 @@ fn corner_smoothing_follows_the_documented_rules() {
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, "boolean groups (the plugin API's corner mixin; the look is pinned)"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {