mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 14:14:41 +00:00
Merge main into #2200
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -395,7 +395,8 @@ impl NodeKind {
|
||||
)
|
||||
}
|
||||
|
||||
/// Kinds whose corners can be rounded from the panel.
|
||||
/// Kinds whose corners can be rounded from the panel. A boolean group has one radius for every
|
||||
/// sharp corner of its result.
|
||||
pub fn has_corner_radius(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -408,16 +409,19 @@ impl NodeKind {
|
||||
| NodeKind::Star { .. }
|
||||
| NodeKind::Vector { .. }
|
||||
| NodeKind::Section { .. }
|
||||
| NodeKind::BooleanOp { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Kinds with a radius per corner (the others round every corner alike).
|
||||
/// Kinds with a radius per corner (the others, boolean groups included, round every corner alike).
|
||||
pub fn has_independent_corners(&self) -> bool {
|
||||
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
|
||||
self.has_corner_radius()
|
||||
&& !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. })
|
||||
}
|
||||
|
||||
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
|
||||
/// has a closed region (an open path's rounded points are drawn without it).
|
||||
/// has a closed region (an open path's rounded points are drawn without it). A boolean group's
|
||||
/// smoothing applies to its result's corners between straight edges.
|
||||
pub fn has_corner_smoothing(&self) -> bool {
|
||||
match self {
|
||||
NodeKind::Vector { network } => !network.regions.is_empty(),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -811,6 +811,21 @@ fn generated_documents_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boolean_groups_corner_radius_round_trips() {
|
||||
let mut doc = Document::new("t");
|
||||
let page = doc.first_page().unwrap();
|
||||
let g = doc.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
doc.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
|
||||
doc.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
|
||||
doc.fit_to_children(g).unwrap();
|
||||
doc.get_mut(g).unwrap().corners = Corners { radii: [20.0; 4], smoothing: 0.6 };
|
||||
let (back, _) = round_trip(&doc, "boolean radius");
|
||||
let n = back.nodes().find(|n| matches!(n.kind, NodeKind::BooleanOp { .. })).unwrap();
|
||||
assert!(n.corners.radii.iter().all(|r| (r - 20.0).abs() < 1e-4), "{:?}", n.corners);
|
||||
assert!((n.corners.smoothing - 0.6).abs() < 1e-4, "{:?}", n.corners);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_keeps_the_details() {
|
||||
let doc = generate(3);
|
||||
|
||||
@@ -290,6 +290,8 @@ fn vectors_ellipses_stars_and_booleans() {
|
||||
counted["pointCount"] = json!(6);
|
||||
let mut boolean = base("Bool", "BOOLEAN_OPERATION");
|
||||
boolean["booleanOperation"] = json!("SUBTRACT");
|
||||
boolean["cornerRadius"] = json!(12);
|
||||
boolean["cornerSmoothing"] = json!(0.6);
|
||||
boolean["children"] =
|
||||
json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]);
|
||||
let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean]));
|
||||
@@ -311,6 +313,8 @@ fn vectors_ellipses_stars_and_booleans() {
|
||||
assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
|
||||
assert_eq!(b.children.len(), 2);
|
||||
assert!(close(b.width, 15.0), "booleans wrap their children");
|
||||
assert_eq!(b.corners.radii, [12.0; 4], "a boolean group keeps its corner radius");
|
||||
assert!(close(b.corners.smoothing, 0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! sweep-line topology from `linesweeper`); results use the even-odd rule.
|
||||
//! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment,
|
||||
//! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]).
|
||||
//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path,
|
||||
//! as a boolean group draws its result.
|
||||
//!
|
||||
//! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a
|
||||
//! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`].
|
||||
@@ -24,6 +26,9 @@ mod dot_dash_tests;
|
||||
#[cfg(test)]
|
||||
mod ops_tests;
|
||||
mod points;
|
||||
mod round;
|
||||
#[cfg(test)]
|
||||
mod round_tests;
|
||||
#[cfg(test)]
|
||||
mod rounded_side_strokes_tests;
|
||||
#[cfg(test)]
|
||||
@@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect
|
||||
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
|
||||
pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity};
|
||||
pub use round::round_corners;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum PathOpsError {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc,
|
||||
//! convex and concave alike, the way a boolean group draws its result.
|
||||
//!
|
||||
//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long,
|
||||
//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each
|
||||
//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges
|
||||
//! beside it can hold ([`Ctx::corner`]).
|
||||
|
||||
use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its
|
||||
/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad.
|
||||
const SHARP_TURN: f64 = 1e-3;
|
||||
/// Accuracy of arc length measurements.
|
||||
const ACC: f64 = 1e-6;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Seg {
|
||||
Line(Point, Point),
|
||||
Cubic(CubicBez),
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn start(&self) -> Point {
|
||||
match self {
|
||||
Self::Line(a, _) => *a,
|
||||
Self::Cubic(c) => c.p0,
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&self) -> Point {
|
||||
match self {
|
||||
Self::Line(_, b) => *b,
|
||||
Self::Cubic(c) => c.p3,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_start(&mut self, p: Point) {
|
||||
match self {
|
||||
Self::Line(a, _) => *a = p,
|
||||
Self::Cubic(c) => c.p0 = p,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line(&self) -> bool {
|
||||
matches!(self, Self::Line(..))
|
||||
}
|
||||
|
||||
/// As a cubic (a line gets its handles at the thirds).
|
||||
fn cubic(&self) -> CubicBez {
|
||||
match self {
|
||||
Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b),
|
||||
Self::Cubic(c) => *c,
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> f64 {
|
||||
match self {
|
||||
Self::Line(a, b) => (*b - *a).hypot(),
|
||||
Self::Cubic(c) => c.arclen(ACC),
|
||||
}
|
||||
}
|
||||
|
||||
/// The length of the control polygon: cheap, and zero only for a point.
|
||||
fn span(&self) -> f64 {
|
||||
let c = self.cubic();
|
||||
(c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot()
|
||||
}
|
||||
|
||||
/// The cubic leaving the start of the segment, or (`from_start` false) leaving its end.
|
||||
fn outward(&self, from_start: bool) -> CubicBez {
|
||||
let c = self.cubic();
|
||||
if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit direction in which `c` leaves its start (skipping coincident handles).
|
||||
fn start_dir(c: &CubicBez) -> Option<Vec2> {
|
||||
[c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot())
|
||||
}
|
||||
|
||||
/// The point `t` along `c` from its start, the unit direction back toward the start there, and the
|
||||
/// parameter reached.
|
||||
fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> {
|
||||
if line {
|
||||
if !len.is_finite() || len <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let u = (c.p3 - c.p0) / len;
|
||||
return Some((c.p0 + u * t, -u, t / len));
|
||||
}
|
||||
let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0);
|
||||
let d = c.deriv().eval(k).to_vec2();
|
||||
let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? };
|
||||
Some((c.eval(k), back, k))
|
||||
}
|
||||
|
||||
/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing
|
||||
/// one, and where those cuts sit on their segments (each segment's own parameter).
|
||||
struct Corner {
|
||||
curves: Vec<CubicBez>,
|
||||
t_in: f64,
|
||||
t_out: f64,
|
||||
}
|
||||
|
||||
/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between
|
||||
/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than
|
||||
/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A
|
||||
/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates
|
||||
/// come back as they are.
|
||||
pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath {
|
||||
if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) {
|
||||
return path.clone();
|
||||
}
|
||||
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut out = BezPath::new();
|
||||
for sub in figcraft_geom::split_subpaths(path) {
|
||||
// Each contour's own size sets its cleanup tolerance, so a large contour can't weld away
|
||||
// the real short edges of a small island or hole beside it.
|
||||
let bb = sub.bounding_box();
|
||||
let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9);
|
||||
let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath));
|
||||
match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() {
|
||||
Some(p) => out.extend(p.elements().iter().copied()),
|
||||
None => out.extend(sub.elements().iter().copied()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped,
|
||||
/// each segment starting where the one before ends, and straight runs merged into one line.
|
||||
fn clean(sub: &BezPath, weld: f64) -> Vec<Seg> {
|
||||
let mut segs: Vec<Seg> = sub
|
||||
.segments()
|
||||
.map(|s| match s {
|
||||
PathSeg::Line(l) => Seg::Line(l.p0, l.p1),
|
||||
other => Seg::Cubic(other.to_cubic()),
|
||||
})
|
||||
.filter(|s| s.span() >= weld)
|
||||
.collect();
|
||||
let n = segs.len();
|
||||
for i in 0..n {
|
||||
let prev_end = segs.get((i + n - 1) % n).map(Seg::end);
|
||||
if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) {
|
||||
s.set_start(p);
|
||||
}
|
||||
}
|
||||
merge_runs(segs, weld)
|
||||
}
|
||||
|
||||
/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C
|
||||
/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic.
|
||||
fn merge_runs(segs: Vec<Seg>, weld: f64) -> Vec<Seg> {
|
||||
let mergeable = |a: &Seg, b: &Seg| match (a, b) {
|
||||
(Seg::Line(p, q), Seg::Line(_, r)) => {
|
||||
let (d, e) = (*r - *p, *q - *p);
|
||||
let l = d.hypot();
|
||||
l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let mut merged: Vec<Seg> = Vec::with_capacity(segs.len());
|
||||
for s in segs {
|
||||
match merged.last_mut() {
|
||||
Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()),
|
||||
_ => merged.push(s),
|
||||
}
|
||||
}
|
||||
while merged.len() > 2 {
|
||||
let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break };
|
||||
if !mergeable(&last, &first) {
|
||||
break;
|
||||
}
|
||||
if let Some(f) = merged.first_mut() {
|
||||
*f = Seg::Line(last.start(), first.end());
|
||||
}
|
||||
merged.pop();
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and
|
||||
/// segment `i` starts.
|
||||
struct Ctx<'a> {
|
||||
segs: &'a [Seg],
|
||||
/// Segment lengths, measured only for segments with a corner at one end.
|
||||
lens: &'a [Option<f64>],
|
||||
/// Per vertex: the turn between its segments and the length it wants along each of them.
|
||||
wants: &'a [Option<(f64, f64)>],
|
||||
radius: f64,
|
||||
smoothing: f64,
|
||||
weld: f64,
|
||||
}
|
||||
|
||||
/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the
|
||||
/// length a corner there wants along each; `None` where the vertex is not a corner.
|
||||
fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec<Option<(f64, f64)>> {
|
||||
let n = segs.len();
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?);
|
||||
let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?);
|
||||
let angle = u1.dot(u2).clamp(-1.0, 1.0).acos();
|
||||
if angle < 1e-6 || PI - angle < SHARP_TURN {
|
||||
return None;
|
||||
}
|
||||
let t = radius / (angle / 2.0).tan();
|
||||
let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t };
|
||||
want.is_finite().then_some((angle, want))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn want(&self, i: usize) -> f64 {
|
||||
self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w)
|
||||
}
|
||||
|
||||
/// What a corner may use of a segment of `len`: half of it, or what the corner at its other
|
||||
/// end leaves, whichever is more.
|
||||
fn room(len: f64, other: f64) -> f64 {
|
||||
(len / 2.0).max(len - other)
|
||||
}
|
||||
|
||||
fn corner(&self, i: usize) -> Option<Corner> {
|
||||
let n = self.segs.len();
|
||||
let (angle, _) = self.wants.get(i).copied().flatten()?;
|
||||
let prev = (i + n - 1) % n;
|
||||
let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?);
|
||||
let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?);
|
||||
let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1)));
|
||||
if !room.is_finite() || room <= self.weld {
|
||||
return None;
|
||||
}
|
||||
let (c1, c2) = (a.outward(false), b.outward(true));
|
||||
if a.is_line() && b.is_line() {
|
||||
self.between_lines(&c1, &c2, room, (la, lb))
|
||||
} else {
|
||||
self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle)
|
||||
}
|
||||
}
|
||||
|
||||
fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option<Corner> {
|
||||
let (u1, u2) = (start_dir(c1)?, start_dir(c2)?);
|
||||
let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?;
|
||||
let (first, last) = (curves.first()?, curves.last()?);
|
||||
let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb);
|
||||
(t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite())))
|
||||
.then_some(Corner { curves, t_in, t_out })
|
||||
}
|
||||
|
||||
/// One cubic between the cuts on both sides, its handles along each side's direction at its cut.
|
||||
fn beside_curve(
|
||||
&self,
|
||||
(c1, line1, la): (&CubicBez, bool, f64),
|
||||
(c2, line2, lb): (&CubicBez, bool, f64),
|
||||
room: f64,
|
||||
angle: f64,
|
||||
) -> Option<Corner> {
|
||||
let t = (self.radius / (angle / 2.0).tan()).min(room);
|
||||
let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?);
|
||||
let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos();
|
||||
let chord = (q - p).hypot();
|
||||
let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) };
|
||||
let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite());
|
||||
ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 })
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds
|
||||
/// (the caller keeps the subpath exactly as it came in).
|
||||
fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option<BezPath> {
|
||||
let segs = clean(sub, weld);
|
||||
let n = segs.len();
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let wants = wants(&segs, radius, smoothing);
|
||||
let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some);
|
||||
let lens: Vec<Option<f64>> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect();
|
||||
let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld };
|
||||
let corners: Vec<Option<Corner>> = (0..n).map(|i| ctx.corner(i)).collect();
|
||||
corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))?
|
||||
}
|
||||
|
||||
/// Walk the segments: what is left of each between the corner at its start and the one at its
|
||||
/// end, then the next corner's curves.
|
||||
fn emit(segs: &[Seg], corners: &[Option<Corner>]) -> Option<BezPath> {
|
||||
let n = segs.len();
|
||||
let mut out = BezPath::new();
|
||||
let first = segs.first()?;
|
||||
out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3));
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
let next = corners.get((i + 1) % n).and_then(Option::as_ref);
|
||||
let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out);
|
||||
let t1 = next.map_or(1.0, |c| c.t_in);
|
||||
if t1 - t0 > 1e-9 {
|
||||
match seg {
|
||||
Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)),
|
||||
Seg::Cubic(c) => {
|
||||
let q = c.subsegment(t0..t1);
|
||||
out.curve_to(q.p1, q.p2, q.p3);
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in next.iter().flat_map(|c| c.curves.iter()) {
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
crate::finite(&out).then_some(out)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular
|
||||
//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the
|
||||
//! edges allow shrinks to fit.
|
||||
|
||||
use figcraft_doc::BoolOp;
|
||||
use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
use figcraft_geom::{ellipse, rounded_rect, split_subpaths};
|
||||
|
||||
use crate::{boolean, boolean_step, round_corners};
|
||||
|
||||
fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
kurbo_rect(x, y, w, h).to_path(0.1)
|
||||
}
|
||||
|
||||
fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect {
|
||||
figcraft_geom::Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
fn l_shape() -> BezPath {
|
||||
boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap()
|
||||
}
|
||||
|
||||
/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement.
|
||||
fn notch() -> BezPath {
|
||||
let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0);
|
||||
boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap()
|
||||
}
|
||||
|
||||
/// Every `CurveTo` as `[from, c1, c2, to]`.
|
||||
fn arcs(path: &BezPath) -> Vec<[Point; 4]> {
|
||||
let mut cur = Point::ZERO;
|
||||
let mut out = Vec::new();
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p,
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
out.push([cur, a, b, c]);
|
||||
cur = c;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
(a - b).hypot() < 1e-3
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// Some curve runs through exactly these four points, in either direction.
|
||||
fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool {
|
||||
let w = want.map(|(x, y)| pt(x, y));
|
||||
let rev = [w[3], w[2], w[1], w[0]];
|
||||
arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q)))
|
||||
}
|
||||
|
||||
/// Some curve joins these two end points, in either direction.
|
||||
fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool {
|
||||
let (f, t) = (pt(from.0, from.1), pt(to.0, to.1));
|
||||
arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f)))
|
||||
}
|
||||
|
||||
fn passes_through(path: &BezPath, p: Point) -> bool {
|
||||
path.elements().iter().any(|el| match el {
|
||||
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn unit(v: Vec2) -> Option<Vec2> {
|
||||
(v.hypot() > 1e-9).then(|| v / v.hypot())
|
||||
}
|
||||
|
||||
fn start_dir(s: PathSeg) -> Option<Vec2> {
|
||||
let c = s.to_cubic();
|
||||
[c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0))
|
||||
}
|
||||
|
||||
fn end_dir(s: PathSeg) -> Option<Vec2> {
|
||||
let c = s.to_cubic();
|
||||
[c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p))
|
||||
}
|
||||
|
||||
/// The largest direction change at any vertex of a closed subpath, in radians.
|
||||
fn sharpest_turn(path: &BezPath) -> f64 {
|
||||
let mut worst: f64 = 0.0;
|
||||
for sub in split_subpaths(path) {
|
||||
let segs: Vec<PathSeg> = sub.segments().collect();
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let next = segs[(i + 1) % segs.len()];
|
||||
if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) {
|
||||
worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos());
|
||||
}
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
fn area(p: &BezPath) -> f64 {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
fn all_finite(p: &BezPath) -> bool {
|
||||
crate::finite(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sharp_corner_of_the_result_rounds_convex_and_concave() {
|
||||
let r = round_corners(&l_shape(), 20.0, 0.0);
|
||||
assert_eq!(arcs(&r).len(), 6);
|
||||
assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)]));
|
||||
assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)]));
|
||||
assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r));
|
||||
assert_eq!(r.winding(pt(1.0, 1.0)), 0);
|
||||
assert_ne!(r.winding(pt(148.0, 102.0)), 0);
|
||||
let bb = r.bounding_box();
|
||||
assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_childs_rounded_corners_are_kept_and_do_not_stack() {
|
||||
let r = round_corners(¬ch(), 20.0, 0.0);
|
||||
assert_eq!(arcs(&r).len(), 8);
|
||||
assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)]));
|
||||
assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)]));
|
||||
assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0)));
|
||||
assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0)));
|
||||
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() {
|
||||
let r = round_corners(¬ch(), 500.0, 0.0);
|
||||
assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)]));
|
||||
assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)]));
|
||||
assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0)));
|
||||
assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0)));
|
||||
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
|
||||
assert!(all_finite(&r));
|
||||
let l = round_corners(&l_shape(), 500.0, 0.0);
|
||||
assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0)));
|
||||
assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0)));
|
||||
assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_sharp_nothing_changes() {
|
||||
let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0);
|
||||
let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0);
|
||||
assert_eq!(round_corners(&round, 20.0, 0.0), round);
|
||||
assert_eq!(round_corners(&oval, 20.0, 0.0), oval);
|
||||
let l = l_shape();
|
||||
for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(round_corners(&l, radius, 0.0), l);
|
||||
}
|
||||
let mut open = BezPath::new();
|
||||
open.move_to((0.0, 0.0));
|
||||
open.line_to((50.0, 0.0));
|
||||
open.line_to((50.0, 50.0));
|
||||
assert_eq!(round_corners(&open, 10.0, 0.0), open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_the_sweep_leaves_on_an_edge_are_not_corners() {
|
||||
let mut p = BezPath::new();
|
||||
for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if i == 0 {
|
||||
p.move_to(q);
|
||||
} else {
|
||||
p.line_to(q);
|
||||
}
|
||||
}
|
||||
p.close_path();
|
||||
let r = round_corners(&p, 500.0, 0.0);
|
||||
assert_eq!(arcs(&r).len(), 4);
|
||||
assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0)));
|
||||
}
|
||||
|
||||
fn two_circles(op: BoolOp) -> BezPath {
|
||||
let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0);
|
||||
boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_beside_curves_round_too() {
|
||||
for op in [BoolOp::Union, BoolOp::Intersect] {
|
||||
let sharp = two_circles(op);
|
||||
assert!(sharpest_turn(&sharp) > 1.0);
|
||||
let r = round_corners(&sharp, 10.0, 0.0);
|
||||
assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r));
|
||||
assert!(all_finite(&r));
|
||||
match op {
|
||||
BoolOp::Union => assert!(area(&r) > area(&sharp)),
|
||||
_ => assert!(area(&r) < area(&sharp)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_draws_the_polygon_profile_between_straight_edges() {
|
||||
let l = l_shape();
|
||||
let plain = round_corners(&l, 20.0, 0.0);
|
||||
let smooth = round_corners(&l, 20.0, 0.6);
|
||||
assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0)));
|
||||
assert!((area(&smooth) - area(&plain)).abs() > 1.0);
|
||||
let circles = two_circles(BoolOp::Union);
|
||||
assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let mut bad = BezPath::new();
|
||||
bad.move_to((f64::NAN, 0.0));
|
||||
bad.line_to((10.0, f64::INFINITY));
|
||||
bad.close_path();
|
||||
assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3);
|
||||
assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty());
|
||||
let mut one = BezPath::new();
|
||||
one.move_to((0.0, 0.0));
|
||||
one.line_to((10.0, 0.0));
|
||||
one.close_path();
|
||||
assert!(all_finite(&round_corners(&one, 5.0, 0.5)));
|
||||
assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0)));
|
||||
let mut zig = BezPath::new();
|
||||
zig.move_to((0.0, 0.0));
|
||||
for i in 1..40_000 {
|
||||
zig.line_to((f64::from(i) * 0.5, f64::from(i % 2)));
|
||||
}
|
||||
zig.close_path();
|
||||
assert!(all_finite(&round_corners(&zig, 2.0, 0.0)));
|
||||
}
|
||||
|
||||
/// The cleanup tolerance is each contour's own: a contour a million units wide doesn't weld away
|
||||
/// the half-unit notch of a separate small contour in the same path.
|
||||
#[test]
|
||||
fn a_large_contour_does_not_weld_a_small_ones_short_edges() {
|
||||
let mut p = rect(0.0, 0.0, 1e6, 1e6);
|
||||
let x = 2e6;
|
||||
for (i, (dx, y)) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.5, 10.0), (5.5, 9.5), (5.0, 9.5), (5.0, 10.0), (0.0, 10.0)].into_iter().enumerate()
|
||||
{
|
||||
if i == 0 { p.move_to((x + dx, y)) } else { p.line_to((x + dx, y)) }
|
||||
}
|
||||
p.close_path();
|
||||
let out = round_corners(&p, 0.001, 0.0);
|
||||
let reaches = |q: Point| {
|
||||
out.elements().iter().any(|el| matches!(el, PathEl::MoveTo(e) | PathEl::LineTo(e) | PathEl::CurveTo(_, _, e) if (*e - q).hypot() < 0.01))
|
||||
};
|
||||
assert!(reaches(pt(x + 5.0, 9.5)) && reaches(pt(x + 5.5, 9.5)), "the notch's floor is kept");
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at
|
||||
//! all. The caches drop their least recently used half when full, so a long drag doesn't evict
|
||||
//! every other group's result.
|
||||
//!
|
||||
//! The group's own corner radius is applied to the finished fold ([`result_cached`]) and cached in a
|
||||
//! separate, smaller map keyed by the folded path plus the radius and smoothing: the fold's caches
|
||||
//! are keyed by the operands alone, so scrubbing the radius never redoes the fold or evicts its
|
||||
//! prefixes, and a rounded path is never stored where a fold would find it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
@@ -109,6 +114,30 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option<BezPath> {
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded() -> &'static Mutex<PathCache> {
|
||||
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(PathCache::new(512)))
|
||||
}
|
||||
|
||||
/// A boolean group's result as it is drawn: the fold of `operands` ([`fold_cached`]), then the
|
||||
/// group's corner `radius` and `smoothing` on every corner still sharp.
|
||||
pub fn result_cached(operands: &[(BezPath, BoolOp)], radius: f64, smoothing: f64) -> Option<BezPath> {
|
||||
let folded = fold_cached(operands)?;
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return Some(folded);
|
||||
}
|
||||
let mut h = DefaultHasher::new();
|
||||
hash_path(&folded, &mut h);
|
||||
(radius.to_bits(), smoothing.to_bits(), 0x52u8).hash(&mut h);
|
||||
let key = h.finish();
|
||||
if let Some(p) = locked(rounded()).get(key) {
|
||||
return Some((*p).clone());
|
||||
}
|
||||
let out = figcraft_pathops::round_corners(&folded, radius, smoothing);
|
||||
locked(rounded()).insert(key, Arc::new(out.clone()));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `keys[i]`: the cache key of the fold of operands `0..=i`.
|
||||
fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec<u64> {
|
||||
let mut h = DefaultHasher::new();
|
||||
|
||||
@@ -7,12 +7,12 @@ use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
|
||||
use figcraft_geom::kurbo::{BezPath, Shape};
|
||||
|
||||
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
|
||||
pub(crate) fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
|
||||
let i = ((y * p.width() + x) * 4) as usize;
|
||||
p.data().get(i).is_some_and(|r| *r < 40)
|
||||
}
|
||||
|
||||
fn render(d: &Document, p: NodeId) -> Pixmap {
|
||||
pub(crate) fn render(d: &Document, p: NodeId) -> Pixmap {
|
||||
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId
|
||||
(d, p, b, kids)
|
||||
}
|
||||
|
||||
fn outline(d: &Document, id: NodeId) -> BezPath {
|
||||
pub(crate) fn outline(d: &Document, id: NodeId) -> BezPath {
|
||||
node_outline(d, d.node(id).unwrap()).unwrap().0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! A boolean group's corner radius: it rounds the result the canvas draws, and keys a cache of its
|
||||
//! own so the fold underneath is not redone.
|
||||
|
||||
use crate::boolean::boolean_steps;
|
||||
use crate::boolean_cache_tests::{dark, outline, render};
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
|
||||
use figcraft_geom::kurbo::Shape;
|
||||
|
||||
/// The L of the measurement: a union of 200 by 100 and 100 by 200 rectangles, on a page of its own.
|
||||
fn l_group() -> (Document, NodeId, NodeId, NodeId) {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let g = d.create(p, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
d.get_mut(g).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
d.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
|
||||
let b = d.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
|
||||
d.fit_to_children(g).unwrap();
|
||||
(d, p, g, b)
|
||||
}
|
||||
|
||||
fn set_radius(d: &mut Document, g: NodeId, r: f64) {
|
||||
d.get_mut(g).unwrap().corners.radii = [r; 4];
|
||||
}
|
||||
|
||||
fn area(d: &Document, g: NodeId) -> f64 {
|
||||
outline(d, g).area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boolean_groups_radius_rounds_what_the_canvas_draws() {
|
||||
let (mut d, p, g, _) = l_group();
|
||||
let pix = render(&d, p);
|
||||
assert!(dark(&pix, 1, 1) && !dark(&pix, 148, 102));
|
||||
set_radius(&mut d, g, 20.0);
|
||||
let pix = render(&d, p);
|
||||
assert!(!dark(&pix, 1, 1), "the corner is cut off");
|
||||
assert!(dark(&pix, 148, 102), "the concave corner is filled in");
|
||||
assert!(dark(&pix, 100, 50));
|
||||
assert!((area(&d, g) - 34656.6).abs() < 1.0, "{}", area(&d, g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radius_keys_the_result_and_the_fold_is_not_redone() {
|
||||
let (mut d, _, g, b) = l_group();
|
||||
let sharp = outline(&d, g);
|
||||
set_radius(&mut d, g, 20.0);
|
||||
let _ = outline(&d, g);
|
||||
let before = boolean_steps();
|
||||
set_radius(&mut d, g, 40.0);
|
||||
// Four net corners of 343.36 each, plus the cubic arcs' own slight excess over a circle.
|
||||
assert!((area(&d, g) - 33626.6).abs() < 2.0, "{}", area(&d, g));
|
||||
assert_eq!(boolean_steps(), before, "a new radius reuses the fold");
|
||||
set_radius(&mut d, g, 0.0);
|
||||
assert_eq!(outline(&d, g), sharp, "radius 0 draws the fold itself");
|
||||
set_radius(&mut d, g, 20.0);
|
||||
let rounded = area(&d, g);
|
||||
d.get_mut(b).unwrap().transform = figcraft_geom::Affine::translate((10.0, 0.0)) * d.node(b).unwrap().transform;
|
||||
assert!((area(&d, g) - rounded).abs() > 1.0, "moving an operand does not draw a stale rounded path");
|
||||
}
|
||||
@@ -347,8 +347,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
|
||||
Some((p, c.bool_op.unwrap_or(*op)))
|
||||
})
|
||||
.collect();
|
||||
// Past the path operations' caps the group has no outline: it draws without its result.
|
||||
crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd))
|
||||
// Past the path operations' caps the group has no outline: it draws without its result. The
|
||||
// group's corner radius rounds what is still sharp in the fold.
|
||||
crate::boolean::result_cached(&operands, n.corners.radii[0], n.corners.smoothing).map(|p| (p, FillRule::EvenOdd))
|
||||
}
|
||||
NodeKind::Text(_) => {
|
||||
let (l, dy) = figcraft_text::layout_node(n)?;
|
||||
|
||||
@@ -550,6 +550,8 @@ mod boolean_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_cache_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_radius_tests;
|
||||
#[cfg(test)]
|
||||
mod bounds_memo_tests;
|
||||
#[cfg(test)]
|
||||
mod dots_tests;
|
||||
|
||||
@@ -166,25 +166,42 @@ fn polygons_and_stars_take_corner_smoothing() {
|
||||
for cmd in ["shape.polygon", "shape.star"] {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// With no corners of their own, the button is named for what it opens.
|
||||
let t = named_button(&mut u, "Corner smoothing");
|
||||
click(&mut u, t);
|
||||
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
|
||||
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
|
||||
let history = u.app.session.undo_depth().0;
|
||||
click(&mut u, smoothing);
|
||||
u.frame(vec![Event::Text("60".into())]);
|
||||
enter(&mut u);
|
||||
u.frame(vec![]);
|
||||
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
|
||||
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing);
|
||||
assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step");
|
||||
takes_corner_smoothing(s, id, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// A boolean group has one radius for its whole result (#2222): like a polygon, its button opens
|
||||
/// corner smoothing, with no field per corner.
|
||||
#[test]
|
||||
fn a_boolean_group_opens_corner_smoothing_and_has_no_corner_fields() {
|
||||
let mut s = Session::new();
|
||||
let rect = |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
let (a, b) = (rect(&mut s, 0), rect(&mut s, 60));
|
||||
let g = NodeId(s.execute("bool.union", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap());
|
||||
takes_corner_smoothing(s, g, "bool.union");
|
||||
}
|
||||
|
||||
/// With `id` rounded at 8 and selected: the panel's button is named Corner smoothing and opens
|
||||
/// the smoothing field with no field per corner, and typing 60 sets the smoothing in one undo step.
|
||||
fn takes_corner_smoothing(mut s: Session, id: NodeId, what: &str) {
|
||||
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// With no corners of their own, the button is named for what it opens.
|
||||
let t = named_button(&mut u, "Corner smoothing");
|
||||
click(&mut u, t);
|
||||
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{what}: one radius, no corner fields");
|
||||
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
|
||||
let history = u.app.session.undo_depth().0;
|
||||
click(&mut u, smoothing);
|
||||
u.frame(vec![Event::Text("60".into())]);
|
||||
enter(&mut u);
|
||||
u.frame(vec![]);
|
||||
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
|
||||
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{what}: {}", n.corners.smoothing);
|
||||
assert_eq!(u.app.session.undo_depth().0, history + 1, "{what}: one undo step");
|
||||
}
|
||||
|
||||
/// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one
|
||||
/// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control.
|
||||
#[test]
|
||||
|
||||
@@ -1732,3 +1732,35 @@ fn alt_held_over_a_field_scrubs_it() {
|
||||
u.frame(vec![]);
|
||||
assert_eq!(geom(&u, a).0, 65.0, "ten more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed() {
|
||||
let (mut u, a, b) = setup();
|
||||
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
|
||||
let r = id(u.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
u.app.run("select.set", json!({"ids": [g]}));
|
||||
assert_eq!(field(&mut u, "Corner radius").1, "0");
|
||||
let steps = undo_steps(&u);
|
||||
type_into(&mut u, "Corner radius", "20", Key::Enter);
|
||||
let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii;
|
||||
assert_eq!(radii(&u, g), [20.0; 4]);
|
||||
assert_eq!(undo_steps(&u), steps + 1, "one undo step");
|
||||
u.app.run("select.set", json!({"ids": [g, r]}));
|
||||
assert_eq!(field(&mut u, "Corner radius").1, "Mixed");
|
||||
type_into(&mut u, "Corner radius", "8", Key::Enter);
|
||||
assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4]));
|
||||
}
|
||||
|
||||
/// A boolean group draws its first radius; per-corner values a script or a file left behind don't
|
||||
/// make its field read Mixed, and a relative edit starts from the radius it draws.
|
||||
#[test]
|
||||
fn a_boolean_groups_field_shows_the_radius_it_draws() {
|
||||
let (mut u, a, b) = setup();
|
||||
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
|
||||
let corners = json!({"topRightRadius": 30, "bottomRightRadius": 30, "bottomLeftRadius": 30});
|
||||
u.app.session.execute("node.set", &json!({"ids": [g], "props": corners})).unwrap();
|
||||
u.app.run("select.set", json!({"ids": [g]}));
|
||||
assert_eq!(field(&mut u, "Corner radius").1, "0", "the radius it draws");
|
||||
type_into(&mut u, "Corner radius", "+10", Key::Enter);
|
||||
assert_eq!(u.app.session.doc().unwrap().node(g).unwrap().corners.radii, [10.0; 4], "0 + 10, not 30 + 10");
|
||||
}
|
||||
|
||||
@@ -52,11 +52,16 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
|
||||
let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round();
|
||||
let op = common(doc, sel, op_of);
|
||||
let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius());
|
||||
// Only rectangles and frames round each corner on its own.
|
||||
// Only rectangles and frames round each corner on its own; a boolean group has one radius for
|
||||
// every sharp corner of its result.
|
||||
let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners());
|
||||
// For polygons, stars and closed vectors the same panel holds only the Corner smoothing row.
|
||||
// For polygons, stars, boolean groups and closed vectors the same panel holds only the Corner
|
||||
// smoothing row.
|
||||
let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing());
|
||||
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
|
||||
// A layer with one radius (polygon, star, boolean group, vector) draws its first one, whatever
|
||||
// the other three hold; a rectangle or frame shows a value only when its four corners agree.
|
||||
let shown = |n: &Node| if n.kind.has_independent_corners() { n.corners.uniform() } else { n.corners.radii.first().copied() };
|
||||
let r = common(doc, sel, |n| shown(n).map(|r| (r * 100.0).round() / 100.0));
|
||||
widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius")));
|
||||
row(ui, 32.0, |ui| {
|
||||
let lead = Lead::Icon(Icon::Opacity, tr("Opacity"));
|
||||
@@ -74,11 +79,13 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
|
||||
"",
|
||||
col,
|
||||
);
|
||||
// A layer with corners of its own starts from its largest.
|
||||
let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
|
||||
// A relative edit starts from the radius shown; a rectangle with differing corners
|
||||
// starts from its largest.
|
||||
let radius_of = |n: &Node| shown(n).unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
|
||||
app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0)));
|
||||
let open = ui.data(|d| d.get_temp::<bool>(Id::unique("indep_corners"))).unwrap_or(false);
|
||||
// Polygons and stars have no corners of their own: the button opens only Corner smoothing.
|
||||
// Polygons, stars and boolean groups have no corners of their own: the button opens only
|
||||
// Corner smoothing.
|
||||
let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") };
|
||||
if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open));
|
||||
|
||||
File diff suppressed because one or more lines are too long
+6
-3
@@ -1,6 +1,6 @@
|
||||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 13: a boolean group's corner radius (#2222); gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
@@ -383,14 +383,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock).
|
||||
|
||||
### 13. Vector tail: none left
|
||||
- **Landed (2026-11-10):** a boolean group takes a corner radius (and smoothing) that rounds every sharp
|
||||
corner of its result, convex and concave, with the children's own radii kept and a radius larger
|
||||
than the edges allow shrinking to fit (#2222; `crates/pathops/src/round_tests.rs`).
|
||||
- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in
|
||||
the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags
|
||||
them, each in its own layer and one undo step for all (`transform_tests`).
|
||||
- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together,
|
||||
sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG,
|
||||
PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a
|
||||
selected open path. Left: corner radius and smoothing on boolean groups that contain
|
||||
rectangles, per-point caps and joins on dashed, variable-width and brush
|
||||
selected open path. Left: per-point caps and joins on dashed, variable-width and brush
|
||||
strokes.
|
||||
- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a
|
||||
subtracted hole reaches what's behind; with the group or an operand selected its operands are
|
||||
@@ -593,6 +595,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Gap 13: a boolean group's corner radius rounds every sharp corner of its result (#2222); the boolean clause leaves the 2026-10-12 "Left" list |
|
||||
| 2026-11-10 | minor | Gap 24: mind maps, first step (`board.mindMap`, `board.mindMapAdd`; the map lays itself out again after every edit, #2226); the toolbar entry, shortcuts, drag to re-parent and `.jam` import are what is missing of them. |
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
|
||||
| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. |
|
||||
|
||||
+1
-1
@@ -768,7 +768,7 @@ page line crosses; parts that come apart move to layers of their own above, list
|
||||
`vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing),
|
||||
`vector.edit.cornerRadius` (rounds the selected sharp points, next to straight or curved segments: a
|
||||
curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported;
|
||||
`node.set {cornerRadius}` on a vector rounds every point), `vector.edit.strokeCap {cap, vertices?}`
|
||||
`node.set {cornerRadius}` on a vector rounds every point, and on a boolean group every sharp corner of its result, while its layers keep their own radius), `vector.edit.strokeCap {cap, vertices?}`
|
||||
and `vector.edit.strokeJoin {join, vertices?}` (the stroke's cap where it ends at these points and
|
||||
its join where it turns there, `null` for the layer's; drawn, exported and outlined),
|
||||
`vector.edit.moveHandles {dx, dy, handles?}` (moves the selected handles by a page offset),
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user