mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 23:01:07 +00:00
Merge main into #2050
This commit is contained in:
@@ -361,6 +361,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged |
|
||||
| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
|
||||
|
||||
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
|
||||
continue;
|
||||
}
|
||||
let Some(&conn) = room.ids.get(&id) else { continue };
|
||||
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
|
||||
stuck.push(conn);
|
||||
|
||||
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
|
||||
@@ -235,6 +235,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: limits.queue.max(4) / 2,
|
||||
block: block.queued.clone(),
|
||||
all: shared.queued.clone(),
|
||||
max: limits.max_queued_bytes,
|
||||
|
||||
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
|
||||
pub(super) struct Charge {
|
||||
n: usize,
|
||||
mine: Arc<AtomicUsize>,
|
||||
waiting: Arc<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
@@ -55,6 +56,7 @@ pub(super) struct Charge {
|
||||
impl Drop for Charge {
|
||||
fn drop(&mut self) {
|
||||
self.mine.fetch_sub(self.n, Ordering::Relaxed);
|
||||
self.waiting.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ pub(crate) enum Push {
|
||||
pub(crate) struct Line {
|
||||
pub(crate) tx: SyncSender<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
/// Messages waiting in the queue.
|
||||
pub(crate) waiting: Arc<AtomicUsize>,
|
||||
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
|
||||
pub(crate) max_presence: usize,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
pub(crate) max: usize,
|
||||
@@ -84,6 +90,15 @@ pub(crate) struct Line {
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
|
||||
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
|
||||
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
|
||||
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
|
||||
/// that reads nothing at all still ends at the write timeout.
|
||||
pub(crate) fn takes_presence(&self) -> bool {
|
||||
self.waiting.load(Ordering::Relaxed) < self.max_presence
|
||||
}
|
||||
|
||||
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
|
||||
/// gets out). `charges` is what the message counts against, shared by its recipients;
|
||||
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
|
||||
@@ -118,7 +133,8 @@ impl Line {
|
||||
}
|
||||
};
|
||||
self.queued.fetch_add(n, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
|
||||
self.waiting.fetch_add(1, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
|
||||
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
|
||||
TrySendError::Full(_) => Push::Full,
|
||||
TrySendError::Disconnected(_) => Push::Gone,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
|
||||
//! of a busy queue.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
(
|
||||
Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: block.clone(),
|
||||
all: all.clone(),
|
||||
max: 1 << 20,
|
||||
max_block,
|
||||
max_all,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
|
||||
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
|
||||
let msg = Bytes::from_static(b"x");
|
||||
for _ in 0..32 {
|
||||
assert!(l.takes_presence());
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert!(!l.takes_presence(), "half full");
|
||||
// Edits still fit in the half kept for them.
|
||||
for _ in 0..32 {
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
|
||||
// It catches up: presence goes in again.
|
||||
for _ in 0..33 {
|
||||
drop(rx.recv().unwrap());
|
||||
}
|
||||
assert!(l.takes_presence());
|
||||
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
|
||||
}
|
||||
|
||||
+23
-4
@@ -14,6 +14,8 @@
|
||||
//! space 0. The session server accepts a new object only with an id in its creator's space or
|
||||
//! space 0, so two people never make the same id.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
|
||||
|
||||
/// What style ids start with.
|
||||
@@ -74,25 +76,42 @@ impl Document {
|
||||
|
||||
/// A new style id.
|
||||
pub fn new_style_id(&mut self) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
|
||||
self.new_style_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
|
||||
/// being imported.
|
||||
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
|
||||
if let Some(n) = self.session_number(taken) {
|
||||
return format!("{STYLE_ID_PREFIX}{n}");
|
||||
}
|
||||
(self.styles.len() + 1..)
|
||||
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
|
||||
.find(|id| self.style(id).is_none())
|
||||
.find(|id| self.style(id).is_none() && !avoid.contains(id))
|
||||
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
|
||||
}
|
||||
|
||||
/// A new variable id.
|
||||
pub fn new_variable_id(&mut self) -> VariableId {
|
||||
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_variable_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
|
||||
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
/// A new variable collection id.
|
||||
pub fn new_collection_id(&mut self) -> CollectionId {
|
||||
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_collection_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable collection id that is none of `avoid` either (see
|
||||
/// [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
|
||||
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
|
||||
//! from the client's own space during one.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
|
||||
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
|
||||
|
||||
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
|
||||
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_can_avoid_ids_about_to_come_in() {
|
||||
let mut d = Document::new("a");
|
||||
d.styles.push(style("S:1"));
|
||||
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
|
||||
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
|
||||
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
|
||||
// In a session the numbers come from the client's space, above those of it to avoid.
|
||||
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
|
||||
d.set_client_id(3);
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_space_counts_every_kind_of_id() {
|
||||
let mut d = Document::new("a");
|
||||
|
||||
@@ -681,6 +681,61 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
assert!(!path.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
|
||||
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
|
||||
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
|
||||
/// lists its segments and whichever way each one runs (#1951, #1506).
|
||||
#[test]
|
||||
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for first in 0..corners.len() {
|
||||
for backwards in [false, true] {
|
||||
for flipped in 0..(1u32 << corners.len()) {
|
||||
let mut net = VectorNetwork::from_points(&corners, true);
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 43.0;
|
||||
}
|
||||
for (i, s) in net.segments.iter_mut().enumerate() {
|
||||
if flipped & (1 << i) != 0 {
|
||||
std::mem::swap(&mut s.a, &mut s.b);
|
||||
std::mem::swap(&mut s.ta, &mut s.tb);
|
||||
}
|
||||
}
|
||||
let lp = &mut net.regions[0].loops[0];
|
||||
lp.rotate_left(first);
|
||||
if backwards {
|
||||
lp.reverse();
|
||||
}
|
||||
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
|
||||
let stroke = net.stroke_path();
|
||||
let fill = net.fill_path();
|
||||
let region = net.rounded().unwrap().region_path(0).unwrap();
|
||||
for (i, p) in corners.iter().enumerate() {
|
||||
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
|
||||
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
|
||||
assert!(keeps_off(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p));
|
||||
}
|
||||
assert!(
|
||||
(length(&fill) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: fill {} and stroke {} trace the same outline",
|
||||
length(&fill),
|
||||
length(&stroke)
|
||||
);
|
||||
assert!(
|
||||
(length(®ion) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: region {} and stroke {} trace the same outline",
|
||||
length(®ion),
|
||||
length(&stroke)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_their_exact_collection() {
|
||||
let mut vars = crate::proto_expr_tests::vars();
|
||||
|
||||
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
|
||||
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
|
||||
let si = net.segments.len() - 1;
|
||||
if ends && let Some(mut lp) = path_between(net, b, a, si) {
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if filled {
|
||||
// A loop broken open at these ends (a point of it deleted, say) closes through the new
|
||||
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
|
||||
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
|
||||
// hole wound against its outline stays a hole whichever end was clicked first. Only a
|
||||
// loop the segment makes one continuous chain is closed; one listed out of walking order
|
||||
// is left as it was.
|
||||
let segs = &net.segments;
|
||||
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
|
||||
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
|
||||
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
|
||||
let mut closed = l.clone();
|
||||
closed.insert(gap, si);
|
||||
let n = closed.len();
|
||||
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
|
||||
*l = closed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
}
|
||||
@@ -997,6 +1017,49 @@ mod tests {
|
||||
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
|
||||
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
|
||||
// rule leaves it empty.
|
||||
let mut n = square();
|
||||
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
|
||||
n.vertices.push(Vertex::new(x, y));
|
||||
}
|
||||
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
|
||||
n.segments.push(Segment::line(a, b));
|
||||
}
|
||||
n.regions[0].loops.push(vec![7, 6, 5, 4]);
|
||||
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
|
||||
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
|
||||
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
|
||||
// corner is filled), then connect the two open ends again, either end first: the hole is a
|
||||
// triangle, still empty.
|
||||
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
|
||||
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
|
||||
let mut m = n.clone();
|
||||
delete_vertices(&mut m, &[5], false);
|
||||
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
|
||||
let (a, b) = (at(&m, first), at(&m, second));
|
||||
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
let loops = &m.regions[0].loops;
|
||||
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
|
||||
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
|
||||
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
|
||||
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
|
||||
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
|
||||
let mut n = VectorNetwork::from_points(&pts, false);
|
||||
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_merges_overlapping_points_and_connects_ends() {
|
||||
let mut n = open_line();
|
||||
|
||||
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
|
||||
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
|
||||
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
|
||||
}
|
||||
|
||||
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
|
||||
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
|
||||
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
|
||||
for top_first in [true, false] {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
|
||||
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.deleteHeal", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
|
||||
// A region with a fill of its own is drawn through the region path and the planar normaliser.
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
}
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
|
||||
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
|
||||
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
|
||||
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "deleted and connected");
|
||||
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
click(&mut s, 50.0, 0.0);
|
||||
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
|
||||
click(&mut s, x, y);
|
||||
}
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "drawn with the pen");
|
||||
}
|
||||
|
||||
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
|
||||
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
|
||||
/// fill that keeps off every sharp point and traces the same outline as the stroke.
|
||||
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let n = net(s, id);
|
||||
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
|
||||
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
|
||||
let doc = s.doc().unwrap();
|
||||
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
|
||||
let stroke = n.stroke_path();
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
|
||||
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
|
||||
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
|
||||
}
|
||||
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod export;
|
||||
mod import_ids;
|
||||
mod load;
|
||||
mod open;
|
||||
mod save;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Ids a file being imported brings that the document already uses for something else.
|
||||
//!
|
||||
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
|
||||
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
|
||||
//! for unrelated things. Kept as they were, the imported layers would show the document's
|
||||
//! variables and styles instead of their own.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::ids::MODE_ID_INFIX;
|
||||
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
|
||||
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
|
||||
|
||||
/// A style, variable or collection of the file being imported.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Asset<'a> {
|
||||
Style(&'a str),
|
||||
Variable(&'a str),
|
||||
Collection(&'a str),
|
||||
}
|
||||
|
||||
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Renames {
|
||||
styles: BTreeMap<String, String>,
|
||||
variables: BTreeMap<String, String>,
|
||||
collections: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Renames {
|
||||
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
|
||||
///
|
||||
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
|
||||
/// the same content under the same id, naming only things that are shared too (the same file
|
||||
/// imported again). A collection and its variables are shared only all together, so variables
|
||||
/// of one file never join a collection of another. Library copies keep their ids and are shared
|
||||
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
|
||||
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
|
||||
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
|
||||
let (theirs, libs) = (&other.variables, &other.libraries);
|
||||
// Each with whether `doc` has the same under its id (None: nothing there), and whether
|
||||
// it's a library copy.
|
||||
let styles = other.styles.iter().map(|s| {
|
||||
let here = doc.styles.iter().find(|m| m.id == s.id);
|
||||
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
|
||||
});
|
||||
let colls = theirs.collections.iter().map(|c| {
|
||||
let here = doc.variables.collection_by_id(&c.id);
|
||||
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
|
||||
});
|
||||
let vars = theirs.variables.iter().map(|v| {
|
||||
let here = doc.variables.get_by_id(&v.id);
|
||||
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
|
||||
});
|
||||
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
|
||||
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
|
||||
for (a, same, copy) in styles.chain(colls).chain(vars) {
|
||||
match same {
|
||||
None => {
|
||||
differs.insert(a);
|
||||
}
|
||||
Some(same) => {
|
||||
taken.insert(a);
|
||||
if copy {
|
||||
copies.insert(a);
|
||||
} else if !same {
|
||||
differs.insert(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What differs makes what names it differ (library copies aside), and a collection and its
|
||||
// variables differ together.
|
||||
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
|
||||
for v in &theirs.variables {
|
||||
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
|
||||
named_by.entry(coll).or_default().push(var);
|
||||
named_by.entry(var).or_default().push(coll);
|
||||
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
|
||||
named_by.entry(Asset::Variable(r)).or_default().push(var);
|
||||
}
|
||||
}
|
||||
for c in &theirs.collections {
|
||||
if let Some(e) = &c.extends {
|
||||
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
|
||||
}
|
||||
}
|
||||
for s in &other.styles {
|
||||
for v in s.bound.values() {
|
||||
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
|
||||
}
|
||||
}
|
||||
let mut todo: Vec<Asset> = differs.iter().copied().collect();
|
||||
while let Some(a) = todo.pop() {
|
||||
for &n in named_by.get(&a).into_iter().flatten() {
|
||||
if !copies.contains(&n) && differs.insert(n) {
|
||||
todo.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
|
||||
Renames {
|
||||
collections: fresh(
|
||||
theirs.collections.iter().map(|c| &c.id),
|
||||
|id| renamed(Asset::Collection(id)),
|
||||
|avoid| doc.new_collection_id_avoiding(avoid),
|
||||
),
|
||||
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
|
||||
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
|
||||
}
|
||||
|
||||
/// `other` with the renamed styles, variables and collections under their new ids, and what
|
||||
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
|
||||
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
|
||||
/// extended collections and their variables extend; and the ids of the renamed collections'
|
||||
/// modes, which name their collection (`<collection>/mode<k>`).
|
||||
pub(super) fn applied(&self, other: &Document) -> Document {
|
||||
let theirs = &other.variables;
|
||||
let mut doc = other.clone();
|
||||
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
|
||||
for s in &mut doc.styles {
|
||||
rename(styles, &mut s.id);
|
||||
s.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
}
|
||||
for c in &mut doc.variables.collections {
|
||||
for m in &mut c.modes {
|
||||
self.mode(theirs, &c.id, &mut m.id);
|
||||
}
|
||||
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
|
||||
rename(colls, id);
|
||||
}
|
||||
}
|
||||
for v in &mut doc.variables.variables {
|
||||
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
val.references_mut().into_iter().for_each(|r| rename(vars, r));
|
||||
(mode, val)
|
||||
});
|
||||
v.values = values.collect();
|
||||
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
mode
|
||||
});
|
||||
v.overrides = overrides.collect();
|
||||
rename(colls, &mut v.collection);
|
||||
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
|
||||
rename(vars, id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
|
||||
for s in links.into_iter().flatten() {
|
||||
rename(styles, s);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
|
||||
}
|
||||
n.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
n.var_modes = std::mem::take(&mut n.var_modes)
|
||||
.into_iter()
|
||||
.map(|(mut c, mut mode)| {
|
||||
self.mode(theirs, &c, &mut mode);
|
||||
rename(colls, &mut c);
|
||||
(c, mode)
|
||||
})
|
||||
.collect();
|
||||
for it in &mut n.interactions {
|
||||
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
|
||||
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
|
||||
/// collection has the modes of the one it extends). Library copies keep their library's mode
|
||||
/// ids, which may name one of the file's own collections.
|
||||
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
|
||||
let mut at = Some(of);
|
||||
for _ in 0..=theirs.collections.len() {
|
||||
let Some(c) = at else { return };
|
||||
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
|
||||
if let Some(new) = self.collections.get(c) {
|
||||
*mode = format!("{new}{MODE_ID_INFIX}{k}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename what one prototype action names (not the actions nested in it).
|
||||
fn action(&self, theirs: &Variables, a: &mut Action) {
|
||||
match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
rename(&self.variables, variable);
|
||||
if let Some(e) = expr {
|
||||
self.expr(theirs, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
self.mode(theirs, collection, mode);
|
||||
rename(&self.collections, collection);
|
||||
}
|
||||
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
|
||||
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
|
||||
Expr::Op { .. } => {}
|
||||
Expr::Mode { collection } => rename(&self.collections, collection),
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = theirs.get_by_id(variable) {
|
||||
self.mode(theirs, &v.collection, mode);
|
||||
}
|
||||
rename(&self.variables, variable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
|
||||
fn fresh<'a>(
|
||||
ids: impl Iterator<Item = &'a String> + Clone,
|
||||
pick: impl Fn(&str) -> bool,
|
||||
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for id in ids.filter(|id| pick(id)) {
|
||||
let new = new_id(&avoid);
|
||||
avoid.insert(new.clone());
|
||||
out.insert(id.clone(), new);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `id` as `map` renames it.
|
||||
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
|
||||
if let Some(new) = map.get(id.as_str()) {
|
||||
*id = new.clone();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::import_ids::Renames;
|
||||
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
|
||||
use super::*;
|
||||
|
||||
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
|
||||
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
|
||||
/// their source and interactions and flows their targets wherever those are. Styles, variables and
|
||||
/// collections whose id the document already has are kept as they are.
|
||||
/// collections the document already has (the same under the same id, as when a file comes in
|
||||
/// again) and library copies are shared; others whose id the document uses for something else get
|
||||
/// new ids, and what comes in names them by those ([`Renames`]).
|
||||
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
let renames = Renames::new(doc, other);
|
||||
let renamed;
|
||||
let other = if renames.is_empty() {
|
||||
other
|
||||
} else {
|
||||
renamed = renames.applied(other);
|
||||
&renamed
|
||||
};
|
||||
for (k, v) in &other.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `file.import` of a file whose style, variable and collection ids the document already uses for
|
||||
//! other things: ids are numbered per file, so two files made apart share them. The imported
|
||||
//! layers keep their own variables, modes and styles, and a file imported again still shares what
|
||||
//! it brought the first time.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::{Document, Node, NodeId, VariableValue};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::ex;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn var_id(s: &Session, name: &str) -> String {
|
||||
s.doc().unwrap().variables.get(name).unwrap().id.clone()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
|
||||
/// and a frame filled with `a1`.
|
||||
fn host() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
|
||||
for i in 1..=4 {
|
||||
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
|
||||
}
|
||||
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
|
||||
(s, f)
|
||||
}
|
||||
|
||||
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
|
||||
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
|
||||
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
|
||||
/// `size` to `size + 1`, and `Styled` uses `Brand`.
|
||||
fn other() -> Session {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
|
||||
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
|
||||
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
|
||||
let actions = json!([
|
||||
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
|
||||
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
|
||||
]);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
|
||||
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
|
||||
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, file: &Session) -> Value {
|
||||
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
|
||||
}
|
||||
|
||||
/// The layers on the pages after the first `kept` pages.
|
||||
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
|
||||
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
|
||||
}
|
||||
|
||||
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
|
||||
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
|
||||
let (mut s, frame) = host();
|
||||
let file = other();
|
||||
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
|
||||
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables.variables.len(), 4 + 3);
|
||||
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
|
||||
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
|
||||
// The document's own are as they were.
|
||||
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
|
||||
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
|
||||
assert_eq!(doc.styles[0], before.styles[0]);
|
||||
// Everything imported names B's variables, B and Brand.
|
||||
let b = doc.variables.collection("B").unwrap().id.clone();
|
||||
let imported = layers_after(doc, before.pages.len());
|
||||
assert!(imported.iter().any(|n| !n.bound.is_empty()));
|
||||
for n in imported {
|
||||
for v in n.bound.values() {
|
||||
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
|
||||
}
|
||||
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
|
||||
if let Some(st) = &n.styles.fill {
|
||||
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
|
||||
}
|
||||
}
|
||||
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
|
||||
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
|
||||
// Bindings applied again leave the imported layers alone.
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch").id;
|
||||
let a2 = var_id(&s, "a2");
|
||||
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
|
||||
assert_eq!(fill(&s, swatch), "004000");
|
||||
assert_eq!(fill(&s, frame), "A10000");
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
|
||||
let (mut s, _) = host();
|
||||
let file = other();
|
||||
let kept = s.doc().unwrap().pages.len();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
let vars = &doc.variables;
|
||||
let b = vars.collection("B").unwrap();
|
||||
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
|
||||
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
|
||||
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
|
||||
// B's mode ids name B's new id, and its variables' values are by those.
|
||||
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
|
||||
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
|
||||
// The frame shows B in Dark: its mode is one of B's.
|
||||
let card = layer(doc, kept, "Card");
|
||||
let dark = b.find_mode("Dark").unwrap().id.clone();
|
||||
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
|
||||
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
|
||||
let swatch = layer(doc, kept, "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
|
||||
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
|
||||
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
|
||||
let actions = &swatch.interactions[0].actions;
|
||||
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
|
||||
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
|
||||
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_the_same_file_again_shares_what_it_brought() {
|
||||
let file = other();
|
||||
let mut s = other();
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables, before.variables);
|
||||
assert_eq!(doc.styles, before.styles);
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
|
||||
// A file imported into an empty one, then again: one copy of its variables.
|
||||
let mut t = Session::new();
|
||||
import(&mut t, &file);
|
||||
import(&mut t, &file);
|
||||
let doc = t.doc().unwrap();
|
||||
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
|
||||
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
|
||||
}
|
||||
@@ -371,6 +371,8 @@ mod images_left_in_file_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
|
||||
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
|
||||
let d = dir("older-copies");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
theme(&mut lib);
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
|
||||
let old_path = path_str(&d.join("old.figcraft"));
|
||||
ex(&mut s, "file.saveAs", json!({"path": old_path}));
|
||||
// The copies take an update; the saved file keeps the older ones under the same ids.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
ex(&mut s, "file.import", json!({"path": old_path}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
|
||||
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
|
||||
assert_eq!(swatches.len(), 2);
|
||||
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(©_of(&s, "primary").id)));
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_variable_commands_fail_cleanly() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -37,6 +37,7 @@ impl FigApp {
|
||||
field_focus: false,
|
||||
panels_unsaved: false,
|
||||
control_queue: Vec::new(),
|
||||
control_waits: Default::default(),
|
||||
control_input: Vec::new(),
|
||||
frame_stats: frame_stats::FrameStats::default(),
|
||||
windows: windows::Windows::default(),
|
||||
|
||||
@@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool {
|
||||
visibility(ctx, id) == Some(false)
|
||||
}
|
||||
|
||||
/// How long, in seconds, a window may draw no frame while a control request waits for it before
|
||||
/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's
|
||||
/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers.
|
||||
pub const NOT_DRAWING_AFTER: f64 = 0.2;
|
||||
|
||||
/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it.
|
||||
/// The platform may still call it visible: under Wayland an app can't tell that its window is
|
||||
/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the
|
||||
/// window draws.
|
||||
fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool {
|
||||
let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id));
|
||||
let wait = app.control_waits.entry(id).or_insert((pass, now));
|
||||
if wait.0 != pass {
|
||||
*wait = (pass, now);
|
||||
}
|
||||
now - wait.1 >= NOT_DRAWING_AFTER
|
||||
}
|
||||
|
||||
/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or
|
||||
/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours
|
||||
/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains
|
||||
@@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
|
||||
continue;
|
||||
}
|
||||
let is_hidden = hidden(ctx, target);
|
||||
let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now);
|
||||
if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) {
|
||||
// Hidden, minimised, or with visibility the platform has not reported: admit it now
|
||||
// so the request gets its deadline and a forced paint. Waiting for a frame that may
|
||||
@@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) });
|
||||
}
|
||||
// A window a request still waits for is checked again soon: if it draws, its frame answers;
|
||||
// if it doesn't (minimised under Wayland), the next logic pass does.
|
||||
let waiting: std::collections::BTreeSet<u64> =
|
||||
app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect();
|
||||
app.control_waits.retain(|id, _| waiting.contains(id));
|
||||
if !waiting.is_empty() {
|
||||
crate::repaint_in(ctx, NOT_DRAWING_AFTER);
|
||||
}
|
||||
request_paint(app, ctx);
|
||||
}
|
||||
|
||||
|
||||
@@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no
|
||||
/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that
|
||||
/// window's frame is answered from the logic passes once the window has drawn nothing for
|
||||
/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws
|
||||
/// keeps answering from its frame.
|
||||
#[test]
|
||||
fn a_window_that_stopped_drawing_answers_from_its_logic_passes() {
|
||||
let (mut h, text) = document();
|
||||
h.settle();
|
||||
h.time += 120.0;
|
||||
h.app.clock.set_test_now(Some(h.time));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let rename = |id: u64, name: &str| ControlRequest {
|
||||
id: json!(id),
|
||||
method: "engine.execute".into(),
|
||||
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
|
||||
reply: tx.clone(),
|
||||
state: Default::default(),
|
||||
};
|
||||
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
|
||||
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
|
||||
// What Wayland reports for a minimised window: nothing about its visibility.
|
||||
let unknown = RawInput::default();
|
||||
let ctx = h.ctx.clone();
|
||||
h.app.control_queue.push(rename(1, "while minimised"));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame");
|
||||
assert_eq!(h.app.control_queue.len(), 1);
|
||||
// No frame comes: the next logic pass answers.
|
||||
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
let reply = rx.try_recv().expect("answered while the window draws nothing");
|
||||
assert_eq!(reply.id, json!(1));
|
||||
assert!(reply.result.is_ok(), "{:?}", reply.result);
|
||||
assert_eq!(name(&h), "while minimised");
|
||||
assert!(h.app.control_queue.is_empty(), "nothing waits for the window");
|
||||
// Back on screen: a frame drawn after the wait began starts it again, and the frame answers.
|
||||
h.time += 1.0;
|
||||
h.app.clock.set_test_now(Some(h.time));
|
||||
h.app.control_queue.push(rename(2, "on screen"));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
|
||||
h.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the frame answers");
|
||||
assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_save_writes_a_changed_file_then_rests() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
|
||||
|
||||
@@ -754,6 +754,9 @@ pub struct FigApp {
|
||||
panels_unsaved: bool,
|
||||
/// Control-channel requests waiting for the next frame.
|
||||
pub control_queue: Vec<ControlRequest>,
|
||||
/// Windows a control request waits for: the window's pass count when the wait began and the
|
||||
/// app-clock time it began, to tell a window that stopped drawing ([`control::background`]).
|
||||
control_waits: std::collections::BTreeMap<u64, (u64, f64)>,
|
||||
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
|
||||
pub control_input: Vec<egui::Event>,
|
||||
/// Frames drawn and what asked for them (`ui.inspect`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (corrected the file I/O overview to include the existing best-effort `.fig` import and experimental export; earlier: the `vectordrawable` crate, L4: Android Vector Drawable export; the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
|
||||
|
||||
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
|
||||
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
|
||||
@@ -23,7 +23,7 @@ replaced.
|
||||
| L2 | `figcraft-plugin` | ~1.0k | Plugin manifest and the WebAssembly sandbox (wasmi, metered, memory cap, no file system, network or clock) |
|
||||
| L2 | `figcraft-pathops` | ~1.1k | Booleans (linesweeper), outline stroke with alignment, arrowheads |
|
||||
| L3 | `figcraft-render` | ~5.4k | Scene → pixels on tiny-skia (CPU): isolated layers for opacity/blend/effects, clips, masks, shadows, blurs (uniform, progressive, background), noise, texture, glass, text; banded in parallel; retained rendering of only what changed; levels of detail when zoomed out |
|
||||
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) |
|
||||
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`), best-effort `.fig` import and experimental export (`fig` module) |
|
||||
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
|
||||
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
|
||||
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
|
||||
@@ -67,9 +67,11 @@ express an effect.
|
||||
|
||||
`.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps
|
||||
recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API
|
||||
file JSON with variables and images, SVG, raster images, clipboard
|
||||
content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
|
||||
([`file-format-parity.md`](file-format-parity.md)).
|
||||
file JSON with variables and images, SVG, raster images, clipboard content and `.fig` files (best
|
||||
effort). Exports: PNG, JPG, WebP, SVG, PDF, code and `.fig` (experimental). The `.fig`
|
||||
[reader](../crates/format/src/fig.rs) and [writer](../crates/format/src/fig/export.rs) belong to
|
||||
`figcraft-format`; fidelity limits and validation are documented in
|
||||
[`file-format-parity.md`](file-format-parity.md).
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -83,6 +85,7 @@ input.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Corrected the file I/O overview and format-crate row: best-effort `.fig` import and experimental export already exist in `figcraft-format`. No capability or readiness change. |
|
||||
| 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
|
||||
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
|
||||
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
|
||||
|
||||
@@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded
|
||||
not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new`
|
||||
remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window
|
||||
to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its
|
||||
window is minimised: where the compositor then stops drawing it, a request waits for the window
|
||||
and gets the 60 s timeout error. The browser's shared eframe runner does not implement
|
||||
window is minimised, and the compositor stops asking it to draw: a window that draws no frame for
|
||||
0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's
|
||||
state are answered without a frame (about half a second each), while `ui.screenshot` and
|
||||
`ui.input` keep waiting for one. The browser's shared eframe runner does not implement
|
||||
`RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there.
|
||||
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|---|---|---|---|
|
||||
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| FreeBSD | none | tarball | |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) |
|
||||
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
|
||||
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. |
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error.
|
||||
|
||||
The app keeps answering while its window is minimised or covered by other windows (see
|
||||
`docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and
|
||||
renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture
|
||||
renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland,
|
||||
which can't report a minimised window, about half a second). Only a capture
|
||||
of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once;
|
||||
`screenshot` without `window` renders the document instead.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user