mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
Merge main into #1360
This commit is contained in:
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (images placed at their own size, 2x screenshots in points (#1360); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -352,6 +352,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
|
||||
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
|
||||
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
|
||||
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
|
||||
|
||||
@@ -6,6 +6,7 @@ use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKin
|
||||
|
||||
use crate::change::{Change, Entry, Json};
|
||||
use crate::message::{ClientMsg, ServerMsg};
|
||||
use crate::server::NO_SESSION_SPACE;
|
||||
use crate::sim::Sim;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
@@ -92,6 +93,53 @@ fn client_ids_skip_spaces_used_by_styles_and_comments() {
|
||||
assert_eq!(join(&mut server).id(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_in_the_top_space_does_not_block_joins() {
|
||||
let mut d = Document::new("a");
|
||||
d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", u64::MAX), "Out of range"));
|
||||
let c = comment(u64::from(u32::MAX) << 32, &d);
|
||||
d.comments.push(c);
|
||||
let mut server = Server::new(d, "", Limits::default());
|
||||
let a = join(&mut server);
|
||||
assert!(a.id() > 0 && a.id() < u32::MAX, "{}", a.id());
|
||||
// After a reload the session still takes newcomers and keeps the document as it was.
|
||||
let state = server.state().unwrap();
|
||||
let mut restored = Server::restore(state.clone(), "", Limits::default()).unwrap();
|
||||
let b = join(&mut restored);
|
||||
assert!(b.id() > a.id() && b.id() < u32::MAX, "{}", b.id());
|
||||
assert_eq!(restored.document().styles.len(), 1);
|
||||
assert_eq!(restored.document().comments.len(), 1);
|
||||
// A state saved while the counter was stuck at the top recovers too.
|
||||
let stuck = crate::SessionState { next_client: u32::MAX, ..state };
|
||||
let mut restored = Server::restore(stuck, "", Limits::default()).unwrap();
|
||||
let c = join(&mut restored);
|
||||
assert!(c.id() > a.id() && c.id() < u32::MAX, "{}", c.id());
|
||||
// A new entry may not take an id from the reserved top half of the spaces.
|
||||
for (k, space) in [NO_SESSION_SPACE, u32::MAX].into_iter().enumerate() {
|
||||
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
|
||||
let ServerMsg::Reject { reason, .. } = send(&mut restored, c.id(), 1 + k as u64, vec![put_style(id.clone())]) else {
|
||||
panic!("accepted {id}")
|
||||
};
|
||||
assert!(reason.contains("id space"), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_imported_from_another_session_are_accepted_and_joins_go_on() {
|
||||
let mut server = Server::new(Document::new("a"), "", Limits::default());
|
||||
let a = join(&mut server);
|
||||
// `file.import` copies styles with their ids, made by clients this session hasn't given out
|
||||
// (up to the highest space a member may use).
|
||||
for (k, space) in [a.id() + 1, a.id() + 1000, NO_SESSION_SPACE - 1].into_iter().enumerate() {
|
||||
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
|
||||
assert!(matches!(send(&mut server, a.id(), 1 + k as u64, vec![put_style(id.clone())]), ServerMsg::Changes(_)), "refused {id}");
|
||||
}
|
||||
// After a restart client ids start above them, and there are plenty left.
|
||||
let mut restored = Server::restore(server.state().unwrap(), "", Limits::default()).unwrap();
|
||||
assert_eq!(join(&mut restored).id(), NO_SESSION_SPACE);
|
||||
assert_eq!(join(&mut Server::new(restored.document().clone(), "", Limits::default())).id(), NO_SESSION_SPACE);
|
||||
}
|
||||
|
||||
/// Each client makes `rounds` styles, collections (with an extra mode), variables and comments
|
||||
/// before seeing anyone else's.
|
||||
fn create_everything(doc: &mut Document, who: usize, round: usize) {
|
||||
|
||||
@@ -25,6 +25,12 @@ pub enum Outgoing {
|
||||
/// Why a viewer's batch is refused.
|
||||
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
|
||||
|
||||
/// A new style, variable, collection or comment may not take an id in a space at or above this
|
||||
/// that isn't its creator's. Client ids start above every space in the document after a restart,
|
||||
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
|
||||
/// entries imported from other files' sessions.
|
||||
pub const NO_SESSION_SPACE: u32 = 1 << 31;
|
||||
|
||||
/// What changed a server's lasting state, in order: what a store appends to its log between
|
||||
/// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart
|
||||
/// ([`Server::replay`]). Presence and who is connected aren't in it.
|
||||
@@ -158,7 +164,13 @@ impl Server {
|
||||
server.state = replica;
|
||||
server.session = state.session;
|
||||
server.seq = state.seq;
|
||||
server.next_client = server.next_client.max(state.next_client);
|
||||
// A counter stuck at the top (saved while an out-of-range id in the document blocked
|
||||
// every join) carries on above the ids actually handed out.
|
||||
let saved = match state.next_client {
|
||||
u32::MAX => state.issued.keys().chain(state.log.iter().map(|a| &a.author)).max().map_or(0, |c| c.saturating_add(1)),
|
||||
n => n,
|
||||
};
|
||||
server.next_client = server.next_client.max(saved);
|
||||
server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect();
|
||||
server.deleted = state.deleted;
|
||||
server.removed = state.removed;
|
||||
@@ -500,9 +512,9 @@ impl Server {
|
||||
|
||||
/// The document-level entries (styles, variables, collections, comments) a batch adds or
|
||||
/// removes, each with whether it is there afterwards. A new one is refused when its id lies in
|
||||
/// the space of another client this session gave an id to (unless it was removed before: undo
|
||||
/// puts entries back); ids from before the session (space 0, earlier sessions, other files)
|
||||
/// are fine.
|
||||
/// the space of another client this session gave an id to, or in a space at or above
|
||||
/// [`NO_SESSION_SPACE`] (unless it was removed before: undo puts entries back); ids from
|
||||
/// before the session (space 0, earlier sessions, other files) are fine.
|
||||
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
|
||||
let before = self.state.document();
|
||||
let mut touched: BTreeSet<Entry> = BTreeSet::new();
|
||||
@@ -529,7 +541,7 @@ impl Server {
|
||||
}
|
||||
if now
|
||||
&& !self.removed.contains(&entry)
|
||||
&& let Some(space) = entry.id_space().filter(|s| *s != from && self.issued.contains_key(s))
|
||||
&& let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= NO_SESSION_SPACE))
|
||||
{
|
||||
return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe())));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! picks the group, and a double click goes one level deeper than the selection.
|
||||
|
||||
use crate::geometry::{enter_target, select_target};
|
||||
use crate::{BoolOp, Document, NodeId, NodeKind};
|
||||
use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn page(d: &Document) -> NodeId {
|
||||
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
|
||||
assert_eq!(listed, vec![f, g, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_hit_uses_region_paints_without_layer_fill() {
|
||||
let mut d = Document::new("region hit");
|
||||
let p = page(&d);
|
||||
let network = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
|
||||
segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
|
||||
regions: vec![
|
||||
Region {
|
||||
loops: vec![vec![0, 1, 2, 3]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(id).unwrap().fills.clear();
|
||||
assert!(d.node(id).unwrap().fills.is_empty());
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
|
||||
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_paint_frames_use_per_region_hit_testing() {
|
||||
let mut d = Document::new("inherited region hit");
|
||||
let p = page(&d);
|
||||
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
|
||||
network.regions[0].winding = WindingRule::EvenOdd;
|
||||
network.regions.push(network.regions[0].clone());
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
|
||||
let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
|
||||
assert!(network.regions.iter().all(|region| region.fills.is_none()));
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
|
||||
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
|
||||
let mut d = Document::new("stroke selection");
|
||||
let p = page(&d);
|
||||
let mut network =
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
n.stroke.weight = 1.0;
|
||||
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicks_go_one_level_deeper() {
|
||||
let (d, f, g, b, r1, _) = setup();
|
||||
|
||||
@@ -60,6 +60,9 @@ impl Node {
|
||||
f(&mut prototype.background);
|
||||
}
|
||||
NodeKind::Text(t) => t.for_each_color_mut(f),
|
||||
NodeKind::Vector { network } => {
|
||||
network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for it in &mut self.interactions {
|
||||
|
||||
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
if !outline {
|
||||
for (i, region) in net.regions.iter().enumerate().rev() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = net.stroke_path();
|
||||
let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
|
||||
let stroke_only = n.has_visible_stroke() && !has_fill;
|
||||
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
|
||||
path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
|
||||
}
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,8 @@ impl Document {
|
||||
|
||||
/// The highest client id space any id in the document lies in (layers, comments, styles,
|
||||
/// variables, collections, modes): a session hands out client ids above it, so ids made in
|
||||
/// earlier sessions are never made again.
|
||||
/// earlier sessions are never made again. Space `u32::MAX` doesn't count: numbers too big for
|
||||
/// any client's space land there ([`space_of`]) and no session hands that client id out.
|
||||
pub fn highest_id_space(&self) -> u32 {
|
||||
let nodes = self.ids().map(|id| id.0);
|
||||
let comments = self.comments.iter().map(|c| c.id);
|
||||
@@ -136,6 +137,6 @@ impl Document {
|
||||
let vars = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX));
|
||||
let colls = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX));
|
||||
let modes = self.variables.collections.iter().flat_map(|c| c.modes.iter().filter_map(|m| mode_number(&m.id)));
|
||||
nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).max().unwrap_or(0)
|
||||
nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).filter(|s| *s != u32::MAX).max().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ fn the_highest_space_counts_every_kind_of_id() {
|
||||
assert_eq!(d.highest_id_space(), 9);
|
||||
d.comments.push(comment(11u64 << 32, d.first_page().unwrap()));
|
||||
assert_eq!(d.highest_id_space(), 11);
|
||||
// Numbers past every client's space (they saturate to `u32::MAX`) are no session's.
|
||||
d.styles.push(style(&format!("S:{}", u64::MAX)));
|
||||
d.comments.push(comment(u64::from(u32::MAX) << 32, d.first_page().unwrap()));
|
||||
assert_eq!(d.highest_id_space(), 11);
|
||||
// Other forms are space 0.
|
||||
assert_eq!(id_space("lib:S:99999999999", "S:"), 0);
|
||||
assert_eq!(id_space("S:+5", "S:"), 0);
|
||||
@@ -110,3 +114,11 @@ fn the_highest_space_counts_every_kind_of_id() {
|
||||
assert_eq!(id_space("S:99999999999999999999999", "S:"), 0, "too big for a number");
|
||||
assert_eq!(mode_number("1:0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodeid_rejects_plus_sign() {
|
||||
assert!("+12".parse::<crate::NodeId>().is_err());
|
||||
assert!("0:+12".parse::<crate::NodeId>().is_err());
|
||||
assert_eq!("12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
|
||||
assert_eq!("0:12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ impl std::str::FromStr for NodeId {
|
||||
// Accept "12" and Figma-style "0:12".
|
||||
let t = s.trim();
|
||||
let t = t.rsplit(':').next().unwrap_or(t);
|
||||
if t.starts_with('+') {
|
||||
// `u64::parse` accepts a leading '+'; ids are digits only.
|
||||
return "-".parse::<u64>().map(NodeId);
|
||||
}
|
||||
t.parse::<u64>().map(NodeId)
|
||||
}
|
||||
}
|
||||
@@ -911,6 +915,15 @@ impl Document {
|
||||
pt.source = m.0;
|
||||
}
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut c.kind {
|
||||
for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
|
||||
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
|
||||
&& let Some(m) = map.get(&NodeId(pt.source))
|
||||
{
|
||||
pt.source = m.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reserve_ids(*new_id);
|
||||
self.nodes.insert(*new_id, Arc::new(c));
|
||||
self.dirty.touch(*new_id);
|
||||
|
||||
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
|
||||
/// Image hashes a node paints (fills, strokes, text and text run fills).
|
||||
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
|
||||
paint_images(n.fills.iter().chain(&n.strokes), out);
|
||||
if let NodeKind::Vector { network } = &n.kind {
|
||||
for r in &network.regions {
|
||||
paint_images(r.fills.iter().flatten(), out);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
paint_images(t.style.fills.iter().flatten(), out);
|
||||
for r in &t.runs {
|
||||
|
||||
+31
-3
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paints_participate_in_colors_images_and_pattern_remapping() {
|
||||
let mut d = Document::new("region resources");
|
||||
let p = page(&d);
|
||||
let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
|
||||
let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
|
||||
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
|
||||
network.regions[0].fills = Some(vec![
|
||||
figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
|
||||
figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
|
||||
figcraft_color::Paint::pattern(source.0),
|
||||
]);
|
||||
let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
assert!(used_images(&d).contains("region-image"));
|
||||
let dup = d.duplicate_into(group, p, None).unwrap();
|
||||
let copied_children = d.node(dup).unwrap().children.clone();
|
||||
let copied_source = copied_children[0];
|
||||
let copied_vector = d.node(copied_children[1]).unwrap();
|
||||
let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
|
||||
let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
|
||||
assert_eq!(pattern.source, copied_source.0);
|
||||
|
||||
let red = figcraft_color::Color::from_hex("FF0000").unwrap();
|
||||
d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
|
||||
let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
|
||||
assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_target_rules() {
|
||||
let mut d = Document::new("x");
|
||||
@@ -212,7 +240,7 @@ fn vector_network_paths() {
|
||||
let mut bad = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0)],
|
||||
segments: vec![Segment::line(0, 5)],
|
||||
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
|
||||
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
|
||||
};
|
||||
bad.sanitize();
|
||||
assert!(bad.segments.is_empty());
|
||||
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
|
||||
VectorNetwork {
|
||||
vertices: v1.into_iter().chain(v2).collect(),
|
||||
segments: s1.into_iter().chain(s2).collect(),
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
|
||||
}
|
||||
};
|
||||
let mut d = Document::new("x");
|
||||
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let net = VectorNetwork {
|
||||
vertices: vec![v0, v1],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
|
||||
};
|
||||
let r = net.rounded().unwrap();
|
||||
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
|
||||
//! filled regions made of closed loops.
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
|
||||
use figcraft_geom::{BezPath, Point, Vec2};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{StrokeCap, StrokeJoin};
|
||||
@@ -88,12 +89,44 @@ pub enum WindingRule {
|
||||
EvenOdd,
|
||||
}
|
||||
|
||||
/// The paint coordinate frame retained when a region moves or its vector is refit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RegionPaintSpace {
|
||||
/// Width of the box that defines paint coordinates.
|
||||
pub width: f64,
|
||||
/// Height of the box that defines paint coordinates.
|
||||
pub height: f64,
|
||||
/// Maps paint-local coordinates into this network's local coordinates.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
/// A filled region: loops of segment indices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Region {
|
||||
pub loops: Vec<Vec<usize>>,
|
||||
#[serde(default)]
|
||||
pub winding: WindingRule,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fills: Option<Vec<Paint>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub paint_space: Option<RegionPaintSpace>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// Paint box and transform, falling back to the current node box for old or invalid metadata.
|
||||
pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
|
||||
let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
|
||||
let coefficients = frame.transform.as_coeffs();
|
||||
let valid = frame.width.is_finite()
|
||||
&& frame.height.is_finite()
|
||||
&& frame.width > 0.0
|
||||
&& frame.height > 0.0
|
||||
&& coefficients.iter().all(|value| value.is_finite())
|
||||
&& frame.transform.determinant().is_finite()
|
||||
&& frame.transform.determinant() != 0.0
|
||||
&& frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
|
||||
if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
|
||||
@@ -153,7 +186,7 @@ impl VectorNetwork {
|
||||
let mut regions = Vec::new();
|
||||
if closed && n > 2 {
|
||||
segments.push(Segment::line(n - 1, 0));
|
||||
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
|
||||
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
Self { vertices, segments, regions }
|
||||
}
|
||||
@@ -242,7 +275,7 @@ impl VectorNetwork {
|
||||
}
|
||||
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
|
||||
if !all_loops.is_empty() {
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
net
|
||||
}
|
||||
@@ -637,6 +670,12 @@ impl VectorNetwork {
|
||||
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
|
||||
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
|
||||
}
|
||||
let scale = Affine::scale_non_uniform(sx, sy);
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = scale * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&mut self, dx: f64, dy: f64) {
|
||||
@@ -644,6 +683,12 @@ impl VectorNetwork {
|
||||
v.x += dx;
|
||||
v.y += dy;
|
||||
}
|
||||
let translate = Affine::translate((dx, dy));
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the curves.
|
||||
|
||||
@@ -30,7 +30,12 @@ impl Face {
|
||||
|
||||
/// The fill region for this face.
|
||||
pub fn region(&self) -> Region {
|
||||
Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
|
||||
Region {
|
||||
loops: self.loops.clone(),
|
||||
winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
|
||||
fills: None,
|
||||
paint_space: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Some point strictly inside the face (not in a hole).
|
||||
@@ -115,7 +120,13 @@ impl VectorNetwork {
|
||||
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
|
||||
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
|
||||
pub fn paint_to(&mut self, p: Point, want: Option<bool>) -> Option<bool> {
|
||||
self.paint_to_with(p, want, None)
|
||||
}
|
||||
|
||||
/// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
|
||||
pub fn paint_to_with(&mut self, p: Point, want: Option<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
|
||||
let face = self.face_at(p)?;
|
||||
let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
|
||||
let mut k: Vec<Vec<usize>> = loops
|
||||
.iter()
|
||||
@@ -131,18 +142,52 @@ impl VectorNetwork {
|
||||
let faces = self.faces();
|
||||
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
|
||||
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
|
||||
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
|
||||
self.regions = filled;
|
||||
if legacy {
|
||||
// Legacy vectors use one combined fill rule across all regions.
|
||||
self.regions =
|
||||
faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
|
||||
} else {
|
||||
// Split each styled source region independently to retain its stack and frame.
|
||||
let source_regions = self.regions.clone();
|
||||
let mut filled = Vec::new();
|
||||
for (source_index, source) in source_regions.into_iter().enumerate() {
|
||||
let source_path = self.region_path(source_index);
|
||||
for face in &faces {
|
||||
if face.interior_point().is_some_and(|q| {
|
||||
source_path.as_ref().is_some_and(|path| match source.winding {
|
||||
WindingRule::NonZero => path.winding(q) != 0,
|
||||
WindingRule::EvenOdd => path.winding(q) % 2 != 0,
|
||||
})
|
||||
}) {
|
||||
let mut region = face.region();
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
filled.push(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.regions = filled;
|
||||
}
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(_), None | Some(false)) => {
|
||||
let filled = if legacy {
|
||||
self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
|
||||
} else {
|
||||
self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match ®ion.fills {
|
||||
Some(fills) => fills.iter().any(|paint| paint.visible),
|
||||
None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
|
||||
})
|
||||
};
|
||||
let want = want.unwrap_or(!filled);
|
||||
match (filled, want) {
|
||||
(true, false) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
(None, Some(false)) => Some(false),
|
||||
(None, _) => {
|
||||
(true, true) => Some(true),
|
||||
(false, false) => Some(false),
|
||||
(false, true) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
self.regions.push(face.region());
|
||||
Some(true)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
|
||||
let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
|
||||
assert_eq!(legacy.fills, None);
|
||||
let empty = Region { fills: Some(Vec::new()), ..legacy };
|
||||
let value = serde_json::to_value(empty).unwrap();
|
||||
assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
|
||||
let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
|
||||
assert!(inherited.get("fills").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
|
||||
use crate::RegionPaintSpace;
|
||||
use figcraft_geom::Affine;
|
||||
let mut r = region(Vec::new(), WindingRule::EvenOdd);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
|
||||
assert_eq!(decoded, r);
|
||||
assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
|
||||
r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
}
|
||||
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
|
||||
@@ -20,6 +20,13 @@ pub fn f64_of(v: &Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0-1 fraction: a `%` suffix or a number above 1 is a percentage ("1%" is 0.01, 50 is 0.5).
|
||||
pub fn fraction_of(v: &Value) -> Option<f64> {
|
||||
let f = f64_of(v)?;
|
||||
let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%'));
|
||||
Some(if percent || f > 1.0 { f / 100.0 } else { f })
|
||||
}
|
||||
|
||||
pub fn num(p: &Value, key: &str) -> Option<f64> {
|
||||
get(p, key).and_then(f64_of)
|
||||
}
|
||||
@@ -167,7 +174,7 @@ pub fn color_of(v: &Value) -> Option<Color> {
|
||||
Value::String(s) => Color::parse_css(s),
|
||||
Value::Object(_) => serde_json::from_value::<Color>(v.clone()).ok().map(Color::sanitized),
|
||||
Value::Array(a) => {
|
||||
let c: Vec<f32> = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect();
|
||||
let c = a.iter().map(|x| x.as_f64().map(|f| f as f32)).collect::<Option<Vec<f32>>>()?;
|
||||
match c.as_slice() {
|
||||
[r, g, b] => Some(Color::new(*r, *g, *b, 1.0)),
|
||||
[r, g, b, a] => Some(Color::new(*r, *g, *b, *a)),
|
||||
@@ -278,3 +285,16 @@ pub fn parse<T: serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str) ->
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_of_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn color_of_rejects_non_numeric_array_entries() {
|
||||
assert_eq!(color_of(&json!([1, "x", 0, 0])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, "x"])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, 0.5])), Some(Color::new(1.0, 0.0, 0.0, 0.5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,90 @@ fn failed_group_reopens_documents_closed_inside_it() {
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_each_tabs_history_after_undo_close_and_open() {
|
||||
let mut s = Session::new();
|
||||
let mut originals = Vec::new();
|
||||
for (index, name) in ["First", "Second"].into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
s.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
let created = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} shape"),
|
||||
"commands": [{"command": "shape.rectangle", "params": {
|
||||
"x": index * 20, "y": 0, "width": 10, "height": 10, "props": {"name": name}
|
||||
}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let original = id(&created[0]);
|
||||
s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} name"),
|
||||
"commands": [{"command": "node.set", "params": {"id": original, "props": {"name": format!("{name} renamed")}}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
originals.push(original);
|
||||
}
|
||||
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
|
||||
let before_docs: Vec<_> = s.docs.iter().map(|ds| ds.doc.clone()).collect();
|
||||
let before_labels: Vec<_> = s
|
||||
.docs
|
||||
.iter()
|
||||
.map(|ds| {
|
||||
(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(before_labels[0], before_labels[1]);
|
||||
let active = s.active;
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "edit.undo"},
|
||||
{"command": "file.close", "params": {"index": 0, "force": true}},
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
|
||||
assert_eq!(s.active, active);
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
for (index, ds) in s.docs.iter().enumerate() {
|
||||
assert_eq!(ds.doc, before_docs[index]);
|
||||
assert_eq!(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].0);
|
||||
assert_eq!(ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].1);
|
||||
}
|
||||
// Travel through each restored tab's real history, including the pre-existing redo step.
|
||||
for _ in 0..2 {
|
||||
let index = s.active;
|
||||
let original = originals[index];
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
let name = &before_docs[index].node(original).unwrap().name;
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, format!("{name} renamed"));
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
@@ -87,6 +171,121 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state()
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_undo_steps_it_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0), "the undo step survives the failed batch");
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_history_after_undo_and_redo() {
|
||||
let mut s = Session::new();
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert!(s.undo().unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
let before_labels = s.undo_labels(10);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "edit.redo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
assert_eq!(s.undo_labels(10), before_labels);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(first));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_outer_group_keeps_the_undo_steps_a_nested_batch_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "command.batch", "params": {"commands": [{"command": "edit.undo"}]}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_the_small_history_limit() {
|
||||
let mut s = Session::new();
|
||||
s.ds_mut().unwrap().history.limit = 1;
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert_eq!(s.ds().unwrap().history.limit, 1);
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert_eq!(s.undo_depth(), (1, 0), "new edits still respect the limit");
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(!s.undo().unwrap(), "the oldest step was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
|
||||
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
|
||||
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
|
||||
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
|
||||
//! when nothing is left), so the rest of the drawing stays exactly where it was.
|
||||
//! styled like the top-most fill or stroke covering the first selected region (using that vector
|
||||
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
|
||||
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
|
||||
//! the rest of the drawing stays exactly where it was.
|
||||
//!
|
||||
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
|
||||
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
|
||||
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
|
||||
pub struct Part {
|
||||
pub id: NodeId,
|
||||
pub stroke: bool,
|
||||
/// The source vector region for a fill (`None` for ordinary fills and strokes).
|
||||
#[serde(skip)]
|
||||
pub region: Option<usize>,
|
||||
}
|
||||
|
||||
/// One region of the arrangement, in page coordinates.
|
||||
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
|
||||
return None;
|
||||
}
|
||||
figcraft_render::node_outline(doc, n)?
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n)?,
|
||||
}
|
||||
};
|
||||
p.apply_affine(doc.world_transform(part.id));
|
||||
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
|
||||
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
|
||||
let mut inputs = Vec::new();
|
||||
for id in eligible(&doc, ids) {
|
||||
let before = parts.len();
|
||||
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
|
||||
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
|
||||
&& let Some(o) = world_area(&doc, part)
|
||||
{
|
||||
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
|
||||
}
|
||||
_ => vec![Part { id, stroke: false, region: None }],
|
||||
};
|
||||
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
|
||||
if let Some(o) = world_area(&doc, part) {
|
||||
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
|
||||
}
|
||||
parts.push(part);
|
||||
inputs.push(o);
|
||||
}
|
||||
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
|
||||
Some(net)
|
||||
}
|
||||
|
||||
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for loop_segments in &mut region.loops {
|
||||
for segment in loop_segments {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
|
||||
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
|
||||
}
|
||||
|
||||
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
|
||||
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
|
||||
let old = doc.get(id)?.clone();
|
||||
let (world, network) = match &old.kind {
|
||||
NodeKind::Vector { network } => (doc.world_transform(id), network),
|
||||
_ => return Err(EngineError::Other("expected a vector with regions".into())),
|
||||
};
|
||||
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
|
||||
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
|
||||
}
|
||||
let mut rebuilt = VectorNetwork::default();
|
||||
for (index, area) in areas {
|
||||
let Some(source) = network.regions.get(*index) else { continue };
|
||||
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
|
||||
if part.regions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (width, height, frame) = paint_frame(Some(source), &old);
|
||||
for region in &mut part.regions {
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
|
||||
}
|
||||
append_network(&mut rebuilt, part);
|
||||
}
|
||||
let Some(bounds) = rebuilt.bounds() else {
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
return Ok(());
|
||||
};
|
||||
rebuilt.translate(-bounds.x0, -bounds.y0);
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
n.children.clear();
|
||||
n.corners = Corners::default();
|
||||
n.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
n.width = bounds.width();
|
||||
n.height = bounds.height();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
|
||||
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
let w = doc.world_transform(id);
|
||||
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
crate::vectoredit::refit(doc, id)
|
||||
}
|
||||
|
||||
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
|
||||
/// with the stroke's paints), just above its shape.
|
||||
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
|
||||
/// paints), just above its shape. Vector region paints and their original frame stay attached.
|
||||
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
|
||||
let mut src = doc.get(style.id)?.clone();
|
||||
let source_region = match (&src.kind, style.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
|
||||
_ => None,
|
||||
};
|
||||
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
|
||||
let region_fills = source_region.and_then(|r| r.fills.clone());
|
||||
if style.stroke {
|
||||
src.fills = std::mem::take(&mut src.strokes);
|
||||
src.stroke = StrokeProps::default();
|
||||
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
|
||||
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
|
||||
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let to_world = pw * Affine::translate((bb.x0, bb.y0));
|
||||
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
|
||||
for region in &mut net.regions {
|
||||
region.fills = region_fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
// Merging every region of a single shape would only replace it with a copy of itself.
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
// A vector's distinct region paints are separate fill parts that merging does unify.
|
||||
let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
|
||||
if merge && whole_shape && fill_parts <= 1 {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let mut changed = Vec::new();
|
||||
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
|
||||
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
|
||||
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
|
||||
let stroke = Part { id: src, stroke: true, region: None };
|
||||
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
|
||||
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
|
||||
if a.parts.contains(&stroke) {
|
||||
if let Some(area) = kept(stroke)? {
|
||||
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
n.strokes.clear();
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
let left = if !a.parts.contains(&fill) {
|
||||
None
|
||||
} else if touched_parts.contains(&fill) {
|
||||
kept(fill)?
|
||||
} else {
|
||||
if fill_parts.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
continue;
|
||||
}
|
||||
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
|
||||
if touched_fills.is_empty() {
|
||||
// The fill wasn't picked: the shape stays as it is.
|
||||
changed.push(src);
|
||||
continue;
|
||||
};
|
||||
match left {
|
||||
}
|
||||
if fill_parts.iter().any(|part| part.region.is_some()) {
|
||||
let mut areas = Vec::new();
|
||||
for part in &fill_parts {
|
||||
let Some(region) = part.region else { continue };
|
||||
if let Some(area) = kept(*part)? {
|
||||
areas.push((region, area));
|
||||
}
|
||||
}
|
||||
if areas.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
} else {
|
||||
reshape_vector_regions(doc, src, &areas)?;
|
||||
changed.push(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match kept(fill_parts[0])? {
|
||||
Some(area) => {
|
||||
reshape(doc, src, &area)?;
|
||||
changed.push(src);
|
||||
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"shapeBuilder.merge",
|
||||
"Merge regions",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
true,
|
||||
|s, p| apply(s, "shapeBuilder.merge", p, true)
|
||||
),
|
||||
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shapebuilder_region_paints_tests.rs"]
|
||||
mod region_paints_tests;
|
||||
|
||||
impl Session {
|
||||
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
|
||||
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::shapebuilder::arrangement;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
|
||||
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
|
||||
}
|
||||
|
||||
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for lp in &mut region.loops {
|
||||
for segment in lp {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
}
|
||||
|
||||
fn split_vector(s: &mut Session) -> NodeId {
|
||||
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut network = VectorNetwork::default();
|
||||
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
|
||||
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
id
|
||||
}
|
||||
|
||||
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
|
||||
// This helper's borrow is tied to the session document.
|
||||
network
|
||||
}
|
||||
|
||||
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut path = network(s, id).region_path(index).unwrap();
|
||||
path.apply_affine(doc.world_transform(id));
|
||||
path.area().abs()
|
||||
}
|
||||
|
||||
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(id).unwrap();
|
||||
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
|
||||
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
|
||||
}
|
||||
|
||||
fn assert_same_transform(a: Affine, b: Affine) {
|
||||
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
|
||||
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
|
||||
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
|
||||
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
|
||||
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
|
||||
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
append(&mut net, inner);
|
||||
for region in &mut net.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = None;
|
||||
region.paint_space = None;
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
|
||||
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
|
||||
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
|
||||
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
|
||||
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector));
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(network(&s, vector).regions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
|
||||
region.fills = None;
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
|
||||
}
|
||||
}
|
||||
let before_a = normalized_paint_world(&s, vector, 0);
|
||||
let before_b = normalized_paint_world(&s, vector, 1);
|
||||
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
|
||||
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
|
||||
assert_eq!(network(&s, merged).regions.len(), 1);
|
||||
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
|
||||
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
|
||||
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
append(&mut net, part);
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
|
||||
s.set_selection(vec![vector]);
|
||||
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
|
||||
assert!(error.to_string().contains("too many fill regions"));
|
||||
assert_eq!(network(&s, vector), &net);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_changes_only_the_picked_painted_face() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
network.regions[1].fills = None;
|
||||
}
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
|
||||
assert_eq!(network(&s, vector).regions[1].fills, None);
|
||||
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
|
||||
let mut s = Session::new();
|
||||
// Create crossing first, then vector above it so vector region supplies merge appearance.
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
|
||||
let merged = id(&result);
|
||||
let merged_net = network(&s, merged);
|
||||
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
|
||||
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_region_keeps_a_transformed_source_gradient_frame() {
|
||||
let mut s = Session::new();
|
||||
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
|
||||
transform *= Affine::scale_non_uniform(1.2, 0.8);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.transform = transform;
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
|
||||
let merged = id(&result);
|
||||
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_preserves_holes_and_transformed_gradient_frames() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
let outer_loop = outer.regions[0].loops[0].clone();
|
||||
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
|
||||
append(&mut outer, inner);
|
||||
outer.regions = vec![Region {
|
||||
loops: vec![outer_loop, inner_loop],
|
||||
winding: WindingRule::EvenOdd,
|
||||
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
|
||||
paint_space: None,
|
||||
}];
|
||||
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.kind = NodeKind::Vector { network: outer };
|
||||
node.transform = transform;
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
|
||||
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![overlay, vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
|
||||
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
|
||||
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
|
||||
assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_left_region_keeps_the_right_region_paint_frame() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 1);
|
||||
s.set_selection(vec![vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
|
||||
let node = s.doc().unwrap().node(vector).unwrap();
|
||||
assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
|
||||
/// under it, the others get the same (points outside closed areas are skipped). A vector without
|
||||
/// fills gets the default fill so the painted area shows.
|
||||
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
|
||||
let inherited = doc.get(id)?.fills.clone();
|
||||
let filled = edit(doc, id, |net| {
|
||||
let mut want = None;
|
||||
for p in pts {
|
||||
want = net.paint_to(*p, want).or(want);
|
||||
want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
|
||||
}
|
||||
want
|
||||
})?
|
||||
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
if filled && n.fills.iter().all(|f| !f.visible) {
|
||||
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
|
||||
let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
|
||||
let targets: std::collections::BTreeSet<usize> = match &n.kind {
|
||||
NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
|
||||
_ => std::collections::BTreeSet::new(),
|
||||
};
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
for (index, region) in network.regions.iter_mut().enumerate() {
|
||||
if region.fills.is_none() && !targets.contains(&index) {
|
||||
// Freeze an untouched inherited region before adding a node-wide default.
|
||||
region.fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
n.fills = vec![default];
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
|
||||
if !finite(d) {
|
||||
return;
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in idx {
|
||||
if seen.insert(*i)
|
||||
&& let Some(v) = net.vertices.get_mut(*i)
|
||||
{
|
||||
let seen: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
|
||||
for i in &seen {
|
||||
if let Some(v) = net.vertices.get_mut(*i) {
|
||||
v.x += d.x;
|
||||
v.y += d.y;
|
||||
}
|
||||
}
|
||||
if seen.len() == net.vertices.len() {
|
||||
let translate = Affine::translate(d);
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
|
||||
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set.len() == net.vertices.len() {
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = m * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
|
||||
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
|
||||
let loops: Vec<Vec<usize>> =
|
||||
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding: r.winding });
|
||||
out.regions.push(Region { loops, ..r.clone() });
|
||||
}
|
||||
}
|
||||
reopen_loops(&mut out);
|
||||
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
}
|
||||
}
|
||||
Some(si)
|
||||
@@ -862,6 +875,14 @@ mod tests {
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_part_keeps_region_paint_metadata() {
|
||||
let mut n = square();
|
||||
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
let part = part(&n, &[0, 1, 2, 3]);
|
||||
assert_eq!(part.regions[0].fills, n.regions[0].fills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_follow_mirroring() {
|
||||
let mut n = open_line();
|
||||
@@ -899,7 +920,7 @@ mod tests {
|
||||
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
let p = Point::new(25.0, 10.0);
|
||||
assert!(n.fill_contains(p));
|
||||
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
|
||||
|
||||
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether vector `n` shows a fill (only filled areas are regions).
|
||||
fn filled(n: &figcraft_doc::Node) -> bool {
|
||||
n.fills.iter().any(|f| f.visible)
|
||||
fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
|
||||
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
|
||||
}
|
||||
|
||||
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
|
||||
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
|
||||
return None;
|
||||
}
|
||||
let net = network(doc, ve.id)?;
|
||||
if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
|
||||
let node = doc.node(ve.id)?;
|
||||
if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
|
||||
return None;
|
||||
}
|
||||
let w = doc.world_transform(ve.id);
|
||||
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
|
||||
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
|
||||
return None;
|
||||
}
|
||||
drawn(net).region_at(lp)
|
||||
let drawn = drawn(net);
|
||||
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
|
||||
}
|
||||
|
||||
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
|
||||
|
||||
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The 7.1 region: both squares as one region.
|
||||
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
|
||||
assert!(ve(&s).regions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
|
||||
let mut n = overlapping();
|
||||
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
n.regions[1].fills = Some(Vec::new());
|
||||
let mut s = Session::new();
|
||||
let id = editing_vector(&mut s, n);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
|
||||
assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_and_press_select_a_region_with_all_its_points() {
|
||||
// 20.
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{
|
||||
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
|
||||
targets,
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
|
||||
string, targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index,
|
||||
paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, fraction_of, get, json, num, ok,
|
||||
opt_index, paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -312,8 +312,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
if let Some(o) = get(p, "opacity").and_then(fraction_of) {
|
||||
slot.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
|
||||
@@ -432,5 +432,8 @@ mod rest_import_tests;
|
||||
#[cfg(test)]
|
||||
mod staging_write_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumbnail_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod zip_cap_tests;
|
||||
|
||||
@@ -3,19 +3,38 @@ use super::load::{ext, file_stem, write};
|
||||
use super::open::remember;
|
||||
use super::*;
|
||||
|
||||
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
|
||||
/// Thumbnail PNG of the chosen frame, or the first page's content (file browsers / home screen).
|
||||
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
let page = doc.first_page()?;
|
||||
let b = doc.page_content_bounds(page)?;
|
||||
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
|
||||
let view = figcraft_render::View {
|
||||
origin: (b.x0, b.y0),
|
||||
scale,
|
||||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||||
let render = |page, b: figcraft_geom::Rect, solo| {
|
||||
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
|
||||
let view = figcraft_render::View {
|
||||
origin: (b.x0, b.y0),
|
||||
scale,
|
||||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||||
};
|
||||
let opts = figcraft_render::Options { solo, ..Default::default() };
|
||||
let pix = figcraft_render::render_view(doc, page, &view, &opts)?;
|
||||
figcraft_render::encode_png(&pix).ok()
|
||||
};
|
||||
let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?;
|
||||
figcraft_render::encode_png(&pix).ok()
|
||||
doc.ids()
|
||||
.find_map(|id| {
|
||||
let n = doc.node(id)?;
|
||||
if !n.kind.is_frame_like() || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") {
|
||||
return None;
|
||||
}
|
||||
// Hidden library-store pages are not document pages.
|
||||
let page = doc.page_of(id).filter(|p| doc.pages.contains(p))?;
|
||||
let b = figcraft_render::export_bounds(doc, id)?;
|
||||
if ![b.x0, b.y0, b.x1, b.y1, b.width(), b.height()].into_iter().all(f64::is_finite) || b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
render(page, b, Some(id))
|
||||
})
|
||||
.or_else(|| {
|
||||
let page = doc.first_page()?;
|
||||
render(page, doc.page_content_bounds(page)?, None)
|
||||
})
|
||||
}
|
||||
|
||||
/// The `.figcraft` file for a document: content, thumbnail and version history.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_geom::Affine;
|
||||
|
||||
const RED: Color = Color::from_rgb8_const(255, 0, 0);
|
||||
const GREEN: Color = Color::from_rgb8_const(0, 255, 0);
|
||||
const BLUE: Color = Color::from_rgb8_const(0, 0, 255);
|
||||
|
||||
fn scene() -> (Session, NodeId, NodeId) {
|
||||
let mut doc = Document::new("Thumbnail");
|
||||
let page = doc.first_page().unwrap();
|
||||
let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap();
|
||||
let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap();
|
||||
doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)];
|
||||
doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)];
|
||||
(Session::with_document(doc), cover, wide)
|
||||
}
|
||||
|
||||
fn choose(s: &mut Session, id: NodeId) {
|
||||
s.execute("object.setThumbnail", &json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
fn picture(doc: &Document) -> image::RgbaImage {
|
||||
image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_frame_draws_its_children_without_siblings_or_document_changes() {
|
||||
let (mut s, cover, _) = scene();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap();
|
||||
doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)];
|
||||
// Cropping the whole page to Cover's bounds would still draw this overlapping sibling.
|
||||
let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap();
|
||||
doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)];
|
||||
choose(&mut s, cover);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (200, 200));
|
||||
assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]);
|
||||
assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]);
|
||||
assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap();
|
||||
assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_thumbnail_choice_keeps_the_first_page_content_bounds() {
|
||||
let (s, _, _) = scene();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (400, 31));
|
||||
assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]);
|
||||
assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {
|
||||
let (mut s, cover, wide) = scene();
|
||||
let default = thumbnail(s.doc().unwrap()).unwrap();
|
||||
for value in ["false", "TRUE", ""] {
|
||||
s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into());
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}");
|
||||
}
|
||||
choose(&mut s, wide);
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() };
|
||||
assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
s.doc_mut().unwrap().remove(cover).unwrap();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (400, 67));
|
||||
assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_thumbnail_ranges_and_pages_fall_back() {
|
||||
let (mut s, _, _) = scene();
|
||||
let default = thumbnail(s.doc().unwrap()).unwrap();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.add_page("Invalid cover");
|
||||
let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
choose(&mut s, cover);
|
||||
for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] {
|
||||
let n = s.doc_mut().unwrap().get_mut(cover).unwrap();
|
||||
n.width = width;
|
||||
n.height = height;
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}");
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let n = doc.get_mut(cover).unwrap();
|
||||
n.width = 100.0;
|
||||
n.height = 100.0;
|
||||
n.parent = Some(NodeId(u64::MAX));
|
||||
assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page");
|
||||
doc.get_mut(cover).unwrap().parent = Some(page);
|
||||
doc.pages.retain(|p| *p != page);
|
||||
assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_frame_on_another_page_uses_its_page_background() {
|
||||
let (mut s, _, _) = scene();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.add_page("Cover page");
|
||||
if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind {
|
||||
*background = GREEN;
|
||||
}
|
||||
let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap();
|
||||
let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap();
|
||||
doc.get_mut(cover).unwrap().fills.clear();
|
||||
let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap();
|
||||
doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)];
|
||||
choose(&mut s, cover);
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (160, 80));
|
||||
assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]);
|
||||
assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] {
|
||||
let n = s.doc_mut().unwrap().get_mut(cover).unwrap();
|
||||
n.width = width;
|
||||
n.height = height;
|
||||
assert_eq!(picture(s.doc().unwrap()).dimensions(), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let expected = picture(s.doc().unwrap());
|
||||
let native = file_bytes(s.ds().unwrap()).unwrap();
|
||||
let (fig, _) = super::export::fig_bytes(s.doc().unwrap()).unwrap();
|
||||
for bytes in [native, fig] {
|
||||
let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap();
|
||||
let actual = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
assert_eq!(actual.dimensions(), (200, 200));
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
@@ -181,6 +181,19 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_axis_padding_is_refused_and_keeps_padding() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, _) = al_frame(&mut s);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 16}}));
|
||||
for key in ["paddingHorizontal", "paddingVertical"] {
|
||||
for junk in [json!("abc"), json!({"a": 1}), json!([1, 2])] {
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {key: junk}})).is_err(), "{key}");
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [16.0; 4], "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_alignment_lines_up_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| {
|
||||
let src = id(p, "id").unwrap_or(s.page()?);
|
||||
let src = page_target(s, "page.duplicate", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("Copy of {}", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
|
||||
@@ -9,14 +9,14 @@ use figcraft_doc::{
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::params::{bool_of, color_of, f64_of, get, paints_of};
|
||||
use crate::params::{bool_of, color_of, f64_of, fraction_of, get, paints_of};
|
||||
use crate::{Result, bad};
|
||||
|
||||
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
|
||||
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
@@ -169,7 +169,7 @@ pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
|
||||
fn four(v: &Value) -> Option<[f64; 4]> {
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
|
||||
let f: Vec<f64> = a.iter().map(f64_of).collect::<Option<_>>()?;
|
||||
match f.as_slice() {
|
||||
[x] => Some([*x; 4]),
|
||||
[a, b] => Some([*a, *b, *a, *b]),
|
||||
@@ -487,8 +487,7 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
|
||||
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
|
||||
"opacity" => {
|
||||
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
|
||||
let o = if o > 1.0 { o / 100.0 } else { o };
|
||||
let o = fraction_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
|
||||
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
"blend" | "blendMode" => {
|
||||
@@ -650,13 +649,13 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
@@ -888,20 +887,20 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
}
|
||||
}
|
||||
"innerRadius" | "ratio" => {
|
||||
let r = f64_of(v).unwrap_or(0.38);
|
||||
let r = if r > 1.0 { r / 100.0 } else { r };
|
||||
let r = fraction_of(v).unwrap_or(0.38);
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
|
||||
_ => return Err(bad(cmd, "innerRadius applies to stars")),
|
||||
}
|
||||
}
|
||||
"arcStart" | "arcSweep" | "arcInner" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = if k == "arcInner" { fraction_of(v) } else { f64_of(v) };
|
||||
let val = val.ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => match k {
|
||||
"arcStart" => *start = val.to_radians(),
|
||||
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
|
||||
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
|
||||
_ => *inner = val.clamp(0.0, 0.99),
|
||||
},
|
||||
_ => return Err(bad(cmd, "arc applies to ellipses")),
|
||||
}
|
||||
@@ -1370,4 +1369,13 @@ mod tests {
|
||||
}
|
||||
assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_rejects_non_numeric_entries() {
|
||||
assert_eq!(four(&json!([10, "abc"])), None);
|
||||
assert_eq!(four(&json!([10, "abc", null, 20])), None);
|
||||
assert_eq!(four(&json!([10, 20, 30])), None);
|
||||
assert_eq!(four(&json!([1, 2, 3, 4])), Some([1.0, 2.0, 3.0, 4.0]));
|
||||
assert_eq!(four(&json!([1, 2])), Some([1.0, 2.0, 1.0, 2.0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
|
||||
VectorNetwork, WindingRule, proto,
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
|
||||
StrokeProps, VectorNetwork, WindingRule, proto,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let pw = doc.world_transform(parent);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in &ids {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
|
||||
// booleans flatten to their result; strokes don't get outlined.
|
||||
let mut part = match &n.kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
_ => {
|
||||
let Some((op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||||
figcraft_render::subtree_outline(doc, n, 0)
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&op);
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
for r in &mut part.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let fallback_fills = first
|
||||
.as_ref()
|
||||
.filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
|
||||
.map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
|
||||
let output_fills = first
|
||||
.as_ref()
|
||||
.map(|node| {
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
|
||||
fallback_fills.clone().unwrap_or_default()
|
||||
} else {
|
||||
node.fills.clone()
|
||||
}
|
||||
};
|
||||
// Into the common parent's space.
|
||||
let all: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut net = VectorNetwork::default();
|
||||
for id in &ids {
|
||||
collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
|
||||
};
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let vid = doc.alloc_id();
|
||||
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
|
||||
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
if let Some(f) = first {
|
||||
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
|
||||
vec![Paint::solid(Color::DEFAULT_SHAPE)]
|
||||
} else {
|
||||
f.fills.clone()
|
||||
};
|
||||
v.fills = output_fills;
|
||||
v.strokes = f.strokes.clone();
|
||||
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
|
||||
v.effects = f.effects.clone();
|
||||
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": vid}))
|
||||
}
|
||||
|
||||
fn collect_flatten_parts(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
parent_world: Affine,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
net: &mut VectorNetwork,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(bad("vector.flatten", "group nesting exceeds 64"));
|
||||
}
|
||||
let Some(node) = doc.node(id) else { return Ok(()) };
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
|
||||
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
|
||||
// visible descendants in stacking order. Child clipping remains the container's outline rule.
|
||||
if node.kind.is_frame_like()
|
||||
&& node.fills.iter().any(|paint| paint.visible)
|
||||
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
|
||||
{
|
||||
let frame = VectorNetwork::from_path(&path);
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
|
||||
}
|
||||
for child in &node.children {
|
||||
if !doc.node(*child).is_some_and(|child| child.visible) {
|
||||
continue;
|
||||
}
|
||||
collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let part = match &node.kind {
|
||||
NodeKind::Vector { network } => {
|
||||
let mut part = network.clone();
|
||||
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
|
||||
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
|
||||
part.regions = vec![Region { loops, winding, ..Default::default() }];
|
||||
}
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
}
|
||||
};
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_flatten_part(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
into_parent: Affine,
|
||||
mut part: VectorNetwork,
|
||||
net: &mut VectorNetwork,
|
||||
) {
|
||||
for region in &mut part.regions {
|
||||
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
|
||||
if region.fills.is_none() {
|
||||
let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
|
||||
if effective != output_fills {
|
||||
region.fills = Some(effective.to_vec());
|
||||
}
|
||||
}
|
||||
region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
|
||||
}
|
||||
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
|
||||
..region
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.fills = n.strokes.clone();
|
||||
v.strokes.clear();
|
||||
v.styles.fill = n.styles.stroke;
|
||||
if n.fills.iter().any(|f| f.visible) && closed {
|
||||
let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
|
||||
if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
|
||||
// Keep the fill as its own layer under the outline, grouped.
|
||||
doc.insert(v, parent, idx.map(|x| x + 1))?;
|
||||
let fill_id = i;
|
||||
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let fill = network.fill_path();
|
||||
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
|
||||
let regions = !network.regions.is_empty();
|
||||
let mut net = VectorNetwork::from_path(&simplified);
|
||||
if !regions {
|
||||
let source = network.clone();
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if legacy && !source.regions.is_empty() {
|
||||
let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = source.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
Err(_) => (path, winding),
|
||||
};
|
||||
let combined = Region { winding: output_winding, ..Default::default() };
|
||||
append_region_path(&mut net, &path, &combined, output_winding);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
}
|
||||
}
|
||||
if !source.regions.is_empty() {
|
||||
let mut used = vec![false; source.segments.len()];
|
||||
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
|
||||
if let Some(used) = used.get_mut(*segment) {
|
||||
*used = true;
|
||||
}
|
||||
}
|
||||
let mut open = source.clone();
|
||||
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
}
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
*network = net;
|
||||
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !n.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
};
|
||||
let Ok(mut res) = res else { continue };
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
let mut net = VectorNetwork::from_path(&res);
|
||||
for r in &mut net.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
if d == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let NodeKind::Vector { network: source } = &n.kind else {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut result = result;
|
||||
let bounds = result.bounding_box();
|
||||
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
|
||||
let node = doc.get_mut(i)?;
|
||||
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
|
||||
node.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
node.width = bounds.width();
|
||||
node.height = bounds.height();
|
||||
continue;
|
||||
};
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if source.regions.is_empty() {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let mut res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
nm.height = bb.height();
|
||||
continue;
|
||||
}
|
||||
if legacy {
|
||||
let path = source.fill_path();
|
||||
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut part = VectorNetwork::from_path(&result);
|
||||
let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
|
||||
for region in &mut part.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = source.regions[0].fills.clone();
|
||||
region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
append_network(&mut net, part);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let mut paint_region = region.clone();
|
||||
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
|
||||
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
// A collapsed negative offset removes this region, while other source regions stay.
|
||||
if res.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
|
||||
}
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.width = 0.0;
|
||||
nm.height = 0.0;
|
||||
continue;
|
||||
};
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
|
||||
let mut part = VectorNetwork::from_path(path);
|
||||
for region in &mut part.regions {
|
||||
region.winding = winding;
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
}
|
||||
append_network(net, part);
|
||||
}
|
||||
|
||||
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Join open vectors whose end points touch into one vector.
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.join", s, p)?;
|
||||
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let last = network.vertices.len() - 1;
|
||||
network.segments.push(Segment::line(last, 0));
|
||||
let all: Vec<usize> = (0..network.segments.len()).collect();
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
|
||||
@@ -73,7 +73,12 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
if let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
let dw = w - n.width;
|
||||
let anchor_right = keeps_right_edge(doc, before, n);
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
if anchor_right && let Some(n) = doc.node_mut(id) {
|
||||
n.transform *= figcraft_geom::kurbo::Affine::translate((-dw, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
@@ -158,6 +163,15 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
cmd::comment::follow_anchors(doc, before);
|
||||
}
|
||||
|
||||
/// Whether auto-width text `n` keeps its right edge when its width changes: it was already
|
||||
/// right-aligned auto-width text before the edit and no auto layout flow places it.
|
||||
fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool {
|
||||
use figcraft_doc::{TextAlignH, TextAutoResize};
|
||||
let right_auto = |n: &Node| n.kind.text().is_some_and(|t| t.align_h == TextAlignH::Right && t.auto_resize == TextAutoResize::WidthAndHeight);
|
||||
let in_flow = !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && p.kind.is_frame_like());
|
||||
right_auto(n) && before.node(n.id).is_some_and(right_auto) && !in_flow
|
||||
}
|
||||
|
||||
/// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto
|
||||
/// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or
|
||||
/// layout settings.
|
||||
|
||||
@@ -61,3 +61,13 @@ fn page_commands_still_work_on_pages() {
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_duplicate_rejects_a_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let pages = s.doc().unwrap().pages.clone();
|
||||
let err = s.execute("page.duplicate", &json!({"id": f})).expect_err("duplicate refuses a layer");
|
||||
assert!(err.to_string().contains("is not a page"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().pages, pages, "no page was added");
|
||||
}
|
||||
|
||||
@@ -475,3 +475,29 @@ fn page_navigation_skips_dividers() {
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_percent_strings_always_divide_by_100() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "R", 0.0, 0.0, None);
|
||||
for (v, want) in [(json!("1%"), 0.01), (json!("0.5%"), 0.005), (json!("50%"), 0.5), (json!(1), 1.0), (json!(50), 0.5), (json!(0.25), 0.25)] {
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"opacity": v.clone()}}));
|
||||
let o = s.doc().unwrap().node(r).unwrap().opacity;
|
||||
assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_opacity_star_ratio_and_arc_ratio_read_a_percent_suffix() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "R", 0.0, 0.0, None);
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "color": "#ff0000", "opacity": "1%"}));
|
||||
let fill = s.doc().unwrap().node(r).unwrap().fills[0].opacity;
|
||||
assert!((fill - 0.01).abs() < 1e-6, "fill opacity \"1%\" gave {fill}");
|
||||
let star = id(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"innerRadius": "1%"}})));
|
||||
let figcraft_doc::NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
|
||||
assert!((inner_ratio - 0.01).abs() < 1e-9, "innerRadius \"1%\" gave {inner_ratio}");
|
||||
let arc = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"arcInner": "1%"}})));
|
||||
let figcraft_doc::NodeKind::Ellipse { inner, .. } = s.doc().unwrap().node(arc).unwrap().kind else { panic!("ellipse") };
|
||||
assert!((inner - 0.01).abs() < 1e-9, "arcInner \"1%\" gave {inner}");
|
||||
}
|
||||
|
||||
@@ -208,6 +208,8 @@ impl Session {
|
||||
let pending_mark = self.pending_events.len();
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
// An undo inside pops the undo steps; a failed group has to bring them back.
|
||||
let undo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.undo.clone()).collect();
|
||||
// An edit inside clears the redo steps; a failed group has to bring them back.
|
||||
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
|
||||
let active = self.active;
|
||||
@@ -239,13 +241,13 @@ impl Session {
|
||||
};
|
||||
self.docs.extend(tab);
|
||||
}
|
||||
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
|
||||
for (((uid, doc, selection, page, _, limit), undo), redo) in before.iter().zip(undo_before).zip(redo_before) {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.history.redo = redo;
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
ds.history.undo = undo;
|
||||
ds.history.limit = *limit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,20 @@ fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
|
||||
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_every_region_of_one_stroked_shape_is_a_no_op() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
|
||||
s.set_selection(vec![r]);
|
||||
let count = ex(&mut s, "shapeBuilder.regions", json!({}))["regions"].as_array().unwrap().len();
|
||||
assert!(count > 1, "fill and stroke make several regions");
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = ex(&mut s, "shapeBuilder.merge", json!({"regions": (0..count).collect::<Vec<_>>()}));
|
||||
assert!(out["id"].is_null(), "{out}");
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_regions_survive_junk() {
|
||||
for j in [
|
||||
|
||||
@@ -370,3 +370,168 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
|
||||
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
|
||||
fn test_mono_font() -> &'static str {
|
||||
const FAMILY: &str = "FigCraft Test Mono";
|
||||
static LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
LOADED.get_or_init(|| {
|
||||
let families = figcraft_text::load_font_bytes(synthetic_mono_font(FAMILY));
|
||||
assert_eq!(families, [FAMILY], "the synthetic font loads");
|
||||
});
|
||||
FAMILY
|
||||
}
|
||||
|
||||
fn synthetic_mono_font(family: &str) -> Vec<u8> {
|
||||
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
|
||||
let glyphs: u16 = 1 + (0x7E - 0x20 + 1);
|
||||
let mut head = Vec::new();
|
||||
for x in [0x0001_0000u32, 0x0001_0000, 0, 0x5F0F_3CF5] {
|
||||
head.extend_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
be16(&mut head, 0); // flags
|
||||
be16(&mut head, 1000); // unitsPerEm
|
||||
head.extend_from_slice(&[0; 16]); // created, modified
|
||||
for x in [0u16, (-200i16) as u16, 500, 800, 0, 8, 2, 0, 0] {
|
||||
be16(&mut head, x); // bbox, macStyle, lowestRecPPEM, direction hint, short loca, glyph format
|
||||
}
|
||||
let mut hhea = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
for x in [800u16, (-200i16) as u16, 0, 500, 0, 0, 500, 1, 0, 0, 0, 0, 0, 0, 0, glyphs] {
|
||||
be16(&mut hhea, x);
|
||||
}
|
||||
let mut maxp = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
be16(&mut maxp, glyphs);
|
||||
for x in [0u16, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] {
|
||||
be16(&mut maxp, x);
|
||||
}
|
||||
let mut hmtx = Vec::new();
|
||||
for _ in 0..glyphs {
|
||||
be16(&mut hmtx, 500);
|
||||
be16(&mut hmtx, 0);
|
||||
}
|
||||
let loca = vec![0; 2 * (usize::from(glyphs) + 1)];
|
||||
let glyf = vec![0; 4];
|
||||
// cmap: one Windows Unicode BMP subtable, format 4, 0x20..=0x7E → glyphs 1.. plus the end segment.
|
||||
let mut cmap = Vec::new();
|
||||
for x in [0u16, 1, 3, 1, 0, 12] {
|
||||
be16(&mut cmap, x);
|
||||
}
|
||||
for x in [4u16, 32, 0, 4, 4, 1, 0, 0x7E, 0xFFFF, 0, 0x20, 0xFFFF, (1i16 - 0x20) as u16, 1, 0, 0] {
|
||||
be16(&mut cmap, x);
|
||||
}
|
||||
let strings = [(1u16, family.to_string()), (2, "Regular".into()), (6, family.replace(' ', "") + "-Regular")];
|
||||
let mut name = Vec::new();
|
||||
be16(&mut name, 0);
|
||||
be16(&mut name, strings.len() as u16);
|
||||
be16(&mut name, 6 + 12 * strings.len() as u16);
|
||||
let mut storage = Vec::new();
|
||||
for (id, s) in &strings {
|
||||
let utf16: Vec<u8> = s.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for x in [3u16, 1, 0x409, *id, utf16.len() as u16, storage.len() as u16] {
|
||||
be16(&mut name, x);
|
||||
}
|
||||
storage.extend(utf16);
|
||||
}
|
||||
name.extend(storage);
|
||||
let mut post = 0x0003_0000u32.to_be_bytes().to_vec();
|
||||
post.extend_from_slice(&[0; 28]);
|
||||
let tables: [(&[u8; 4], Vec<u8>); 9] = [
|
||||
(b"cmap", cmap),
|
||||
(b"glyf", glyf),
|
||||
(b"head", head),
|
||||
(b"hhea", hhea),
|
||||
(b"hmtx", hmtx),
|
||||
(b"loca", loca),
|
||||
(b"maxp", maxp),
|
||||
(b"name", name),
|
||||
(b"post", post),
|
||||
];
|
||||
let mut font = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
for x in [tables.len() as u16, 128, 3, 16] {
|
||||
be16(&mut font, x);
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
font.extend_from_slice(*tag);
|
||||
font.extend_from_slice(&0u32.to_be_bytes());
|
||||
font.extend_from_slice(&(offset as u32).to_be_bytes());
|
||||
font.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
font.extend_from_slice(data);
|
||||
font.resize(font.len().next_multiple_of(4), 0);
|
||||
}
|
||||
font
|
||||
}
|
||||
|
||||
/// The world position of a text's top-right corner (where right-aligned text is anchored).
|
||||
fn top_right(s: &Session, t: NodeId) -> (f64, f64) {
|
||||
let doc = s.doc().unwrap();
|
||||
let p = doc.world_transform(t) * figcraft_geom::kurbo::Point::new(doc.node(t).unwrap().width, 0.0);
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn assert_near(a: (f64, f64), b: (f64, f64), why: &str) {
|
||||
assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{why}: {a:?} != {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_auto_width_text_grows_and_shrinks_to_the_left() {
|
||||
let font = test_mono_font();
|
||||
for (src, rotation) in [("ANCHOR TEST\nabc", 0.0), ("abc", 0.0), ("ANCHOR TEST\nabc", 90.0)] {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, src, json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"x": 200, "y": 50, "rotation": rotation}})).unwrap();
|
||||
let anchor = top_right(&s, t);
|
||||
let w0 = size(&s, t).0;
|
||||
let widest = src.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
||||
assert!((w0 - 20.0 * widest as f64).abs() < 1e-6, "{src:?}: the test font measures 20 px a character, not {w0}");
|
||||
begin(&mut s, t, Some(src.chars().count()), false).unwrap();
|
||||
for c in "defghijklmnopqrstuvwxyz".chars() {
|
||||
crate::textedit::insert(&mut s, &c.to_string()).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: typing {c}"));
|
||||
}
|
||||
assert!(size(&s, t).0 > w0 + 1e-6, "{src:?} at {rotation}°: typing grows the text");
|
||||
for _ in 0..23 {
|
||||
key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: deleting"));
|
||||
}
|
||||
assert!((size(&s, t).0 - w0).abs() < 1e-6, "back to the starting width");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_auto_width_growth_undoes_to_its_old_place() {
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "abc", json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}));
|
||||
let before = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t));
|
||||
let anchor = top_right(&s, t);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, "set characters");
|
||||
let after = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t));
|
||||
assert!(after.1.0 > before.1.0 + 1e-6, "the text grew");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), before, "undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), after, "redo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_places_right_aligned_auto_width_text() {
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layout": "horizontal", "padding": 10, "gap": 0}})).unwrap();
|
||||
let t = id(s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"text": "abc", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}}),
|
||||
)
|
||||
.unwrap());
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap();
|
||||
let c = s.doc().unwrap().node(t).unwrap().transform.as_coeffs();
|
||||
assert_eq!((c[4], c[5]), (10.0, 10.0), "the auto layout keeps it at its padding");
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, VectorNetwork};
|
||||
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, Region, Segment, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::ParamCurve;
|
||||
use figcraft_geom::{Point, Shape};
|
||||
use figcraft_geom::{BezPath, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
@@ -17,6 +17,276 @@ use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::vectoredit::{VectorEdit, network};
|
||||
|
||||
#[test]
|
||||
fn region_paints_survive_simplify_offset_and_bucket_normalization() {
|
||||
let mut s = Session::new();
|
||||
let id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), id);
|
||||
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
|
||||
gradient.from = (0.2, 0.1);
|
||||
gradient.to = (0.8, 0.9);
|
||||
let fills = vec![
|
||||
figcraft_color::Paint::gradient(gradient),
|
||||
figcraft_color::Paint::image("test-image", figcraft_color::ImageScaleMode::Crop),
|
||||
figcraft_color::Paint::pattern(123),
|
||||
];
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].fills = Some(fills.clone());
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "simplify keeps the full stack");
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "offset keeps the full stack");
|
||||
|
||||
// Add a divider so the legacy single region must split into independently painted faces.
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
let network = net(&s, id);
|
||||
let (topseg, botseg) = (
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 0.0 && network.vertices[sg.b].y == 0.0).unwrap(),
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 104.0 && network.vertices[sg.b].y == 104.0).unwrap(),
|
||||
);
|
||||
let a = ex(&mut s, "vector.edit.addVertex", json!({"segment": topseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
let b = ex(&mut s, "vector.edit.addVertex", json!({"segment": botseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": a, "b": b}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 25, "y": 52}));
|
||||
let normalized = net(&s, id);
|
||||
assert_eq!(normalized.regions.len(), 1, "unfilled face is removed while the other stays");
|
||||
assert_eq!(normalized.regions[0].fills, Some(fills.clone()), "untouched face keeps the original stack");
|
||||
assert!(!normalized.fill_contains(Point::new(27.0, 54.0)));
|
||||
assert!(normalized.fill_contains(Point::new(77.0, 54.0)));
|
||||
s.undo().unwrap();
|
||||
assert!(net(&s, id).fill_contains(Point::new(25.0, 52.0)), "bucket change is undoable");
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
let flattened_net = net(&s, flattened);
|
||||
assert!(flattened_net.regions.iter().all(|region| region.fills == Some(fills.clone())), "flatten keeps each source region stack");
|
||||
assert!(flattened_net.regions.iter().all(|region| region.paint_space.is_some()), "flatten keeps source paint frames");
|
||||
assert!(flattened_net.fill_contains(Point::new(25.0, 52.0)));
|
||||
let prior_frame =
|
||||
flattened_net.regions[0].paint_frame(s.doc().unwrap().get(flattened).unwrap().width, s.doc().unwrap().get(flattened).unwrap().height);
|
||||
let prior_world_frame = s.doc().unwrap().world_transform(flattened) * prior_frame.2;
|
||||
let regrouped = id_of(&ex(&mut s, "object.group", json!({"ids": [flattened]}))["id"]).unwrap();
|
||||
let flattened_again = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [regrouped]}))["id"]).unwrap();
|
||||
let after = net(&s, flattened_again);
|
||||
let after_frame =
|
||||
after.regions[0].paint_frame(s.doc().unwrap().get(flattened_again).unwrap().width, s.doc().unwrap().get(flattened_again).unwrap().height);
|
||||
let after_world_frame = s.doc().unwrap().world_transform(flattened_again) * after_frame.2;
|
||||
assert!(
|
||||
prior_world_frame.as_coeffs().iter().zip(after_world_frame.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
|
||||
"flattening an already framed gradient preserves its source coordinates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_and_inherited_region_paints_survive_refit() {
|
||||
let mut s = Session::new();
|
||||
let empty_id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), empty_id);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(empty_id).unwrap().kind {
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [empty_id]}));
|
||||
ex(&mut s, "vector.offset", json!({"ids": [empty_id], "offset": 2}));
|
||||
assert_eq!(net(&s, empty_id).regions[0].fills, Some(Vec::new()), "explicit empty paint stack survives refit");
|
||||
|
||||
let inherited_id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), inherited_id);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(inherited_id).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.offset", json!({"ids": [inherited_id], "offset": 2}));
|
||||
let inherited_net = net(&s, inherited_id);
|
||||
let inherited = &inherited_net.regions[0];
|
||||
assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset");
|
||||
let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame");
|
||||
assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_keeps_inherited_region_paints_inherited() {
|
||||
let mut s = Session::new();
|
||||
let inherited_flatten = square(&mut s, false);
|
||||
let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone();
|
||||
ex_to_vector(s.doc_mut().unwrap(), inherited_flatten);
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(inherited_flatten).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [inherited_flatten]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert!(
|
||||
net(&s, flattened).regions.iter().all(|region| region.fills.is_none()),
|
||||
"flatten keeps inherited paints inherited when output node has same fills"
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().get(flattened).unwrap().fills, inherited_fills);
|
||||
|
||||
let plain = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 60}))["id"]).unwrap();
|
||||
let plain_fills = s.doc().unwrap().get(plain).unwrap().fills.clone();
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [plain]}))["id"]).unwrap();
|
||||
let flattened_plain = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert!(net(&s, flattened_plain).regions.iter().all(|region| region.fills.is_none()), "plain shapes inherit the flattened node's fill");
|
||||
assert_eq!(s.doc().unwrap().get(flattened_plain).unwrap().fills, plain_fills);
|
||||
let edited_fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
s.doc_mut().unwrap().get_mut(flattened_plain).unwrap().fills = edited_fills.clone();
|
||||
assert_eq!(net(&s, flattened_plain).regions[0].fills, None, "changing the flattened node fill updates inherited geometry");
|
||||
|
||||
let different_source = square(&mut s, false);
|
||||
s.doc_mut().unwrap().get_mut(different_source).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
ex_to_vector(s.doc_mut().unwrap(), different_source);
|
||||
let source_fills = s.doc().unwrap().get(different_source).unwrap().fills.clone();
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(different_source).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [different_source]}))["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_offset_preserves_the_gradients_world_paint_frame() {
|
||||
let mut s = Session::new();
|
||||
let legacy_gradient = square(&mut s, false);
|
||||
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
|
||||
gradient.from = (0.2, 0.1);
|
||||
gradient.to = (0.8, 0.9);
|
||||
s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().fills = vec![figcraft_color::Paint::gradient(gradient)];
|
||||
ex_to_vector(s.doc_mut().unwrap(), legacy_gradient);
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let before = net(&s, legacy_gradient).regions[0].paint_frame(100.0, 100.0).2;
|
||||
let before_world = s.doc().unwrap().world_transform(legacy_gradient) * before;
|
||||
ex(&mut s, "vector.offset", json!({"ids": [legacy_gradient], "offset": 2}));
|
||||
let after_net = net(&s, legacy_gradient);
|
||||
let after = after_net.regions[0]
|
||||
.paint_frame(s.doc().unwrap().get(legacy_gradient).unwrap().width, s.doc().unwrap().get(legacy_gradient).unwrap().height)
|
||||
.2;
|
||||
let after_world = s.doc().unwrap().world_transform(legacy_gradient) * after;
|
||||
assert!(
|
||||
before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
|
||||
"legacy offset preserves the gradient's world paint frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_fills_an_explicitly_empty_face_and_leaves_the_other_empty() {
|
||||
let mut s = Session::new();
|
||||
let empty_faces =
|
||||
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(empty_faces).unwrap();
|
||||
node.fills.clear();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
assert!(network.regions.iter().all(|region| region.fills.is_none()));
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": empty_faces}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 50, "y": 50}));
|
||||
let split = net(&s, empty_faces);
|
||||
let left =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(50.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
let right =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(250.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
assert_eq!(left.fills, None, "clicking an explicitly empty face fills it");
|
||||
assert_eq!(right.fills, Some(Vec::new()), "untouched inherited-empty face stays empty");
|
||||
assert!(!s.doc().unwrap().get(empty_faces).unwrap().fills.is_empty(), "the painted target gets the layer default fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_simplify_combines_mixed_winding_regions() {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}))["id"]).unwrap();
|
||||
let mut second_path = BezPath::new();
|
||||
second_path.move_to((50.0, 0.0));
|
||||
second_path.line_to((150.0, 0.0));
|
||||
second_path.line_to((150.0, 100.0));
|
||||
second_path.line_to((50.0, 100.0));
|
||||
second_path.close_path();
|
||||
let second = VectorNetwork::from_path(&second_path);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].winding = WindingRule::EvenOdd;
|
||||
let vertex_offset = network.vertices.len();
|
||||
let segment_offset = network.segments.len();
|
||||
network.vertices.extend(second.vertices);
|
||||
network.segments.extend(second.segments.into_iter().map(|segment| Segment {
|
||||
a: segment.a + vertex_offset,
|
||||
b: segment.b + vertex_offset,
|
||||
..segment
|
||||
}));
|
||||
network.regions.extend(second.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + segment_offset).collect()).collect(),
|
||||
..region
|
||||
}));
|
||||
network.regions[1].winding = WindingRule::NonZero;
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
|
||||
let simplified = net(&s, id);
|
||||
assert_eq!(simplified.regions.len(), 1, "legacy regions simplify as one combined fill");
|
||||
assert!(!simplified.fill_contains(Point::new(60.0, 10.0)), "overlap cancels under combined even-odd winding");
|
||||
assert!(!simplified.fill_contains(Point::new(40.0, 40.0)), "nested contour remains a hole");
|
||||
assert!(simplified.fill_contains(Point::new(120.0, 50.0)), "non-overlapping area remains filled");
|
||||
assert!(simplified.regions[0].fills.is_none());
|
||||
assert!(simplified.regions[0].paint_space.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "center starts as an even-odd hole");
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 100, "y": 100}));
|
||||
assert!(net(&s, id).fill_contains(Point::new(100.0, 100.0)), "bucket fills a legacy even-odd hole");
|
||||
s.undo().unwrap();
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let flatten_source =
|
||||
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(flatten_source).unwrap().kind {
|
||||
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [flatten_source]}))["id"]).unwrap();
|
||||
let flattened_legacy = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened_legacy).regions.len(), 1, "flatten combines unstyled legacy loops");
|
||||
assert!(!net(&s, flattened_legacy).fill_contains(Point::new(100.0, 100.0)), "flatten preserves the legacy even-odd hole");
|
||||
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "offset retains the even-odd hole");
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened).regions.len(), 1, "legacy region loops stay grouped");
|
||||
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
|
||||
}
|
||||
|
||||
/// Give vector `id` a painted region, converting a plain shape to a vector first.
|
||||
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
|
||||
let n = doc.get(id).unwrap();
|
||||
let (path, _) = figcraft_render::node_outline(doc, n).unwrap();
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ fn two_squares() -> VectorNetwork {
|
||||
Segment::line(3, 4),
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,8 @@ fn two_lenses() -> VectorNetwork {
|
||||
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,10 @@ fn one_shared_segment() -> VectorNetwork {
|
||||
VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,9 @@ impl<'d> Exporter<'d> {
|
||||
/// The editable network (with per-point corner radii, handle mirroring and end caps) and,
|
||||
/// when it has filled areas, their outline.
|
||||
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
|
||||
if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) {
|
||||
self.warn("vector region paints were left out of the .fig export");
|
||||
}
|
||||
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
|
||||
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
|
||||
self.warn("vector networks too large for the format were written as outlines");
|
||||
|
||||
@@ -827,6 +827,51 @@ fn round_trip_keeps_the_details() {
|
||||
assert_eq!(label.characters, "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_warns_when_region_paints_are_left_out() {
|
||||
let mut doc = generate(3);
|
||||
let Some(id) = doc.nodes().find_map(|node| match &node.kind {
|
||||
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("generated document has no filled vector");
|
||||
};
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = Some(Vec::new());
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
|
||||
let mut doc = generate(3);
|
||||
let id = doc
|
||||
.nodes()
|
||||
.find_map(|node| match &node.kind {
|
||||
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = None;
|
||||
region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
|
||||
let mut plain = doc;
|
||||
let node = plain.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].paint_space = None;
|
||||
let (_, warnings) = export(&plain, None).unwrap();
|
||||
assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_decode_and_list_parents_first() {
|
||||
for seed in 0..20 {
|
||||
|
||||
@@ -248,6 +248,9 @@ struct Mapper<'a> {
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
|
||||
network_ids: HashMap<(usize, u64, u64, usize), usize>,
|
||||
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
|
||||
/// file, since layers with region paints don't share their network.
|
||||
region_paint_elements: usize,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
@@ -322,6 +325,7 @@ impl<'a> Mapper<'a> {
|
||||
reserved: 0,
|
||||
networks: Vec::new(),
|
||||
network_ids: HashMap::new(),
|
||||
region_paint_elements: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::component::{Entry, Inst, OverrideRow, prop_ref};
|
||||
use super::connector::connector_has_saved_label;
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
|
||||
size,
|
||||
@@ -566,6 +566,84 @@ impl<'a> Mapper<'a> {
|
||||
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
|
||||
};
|
||||
let (sx, sy) = (scale("x"), scale("y"));
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
|
||||
out["join"] = json!(join.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> =
|
||||
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
|
||||
let regions: Vec<Value> = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }});
|
||||
if r.style != 0 {
|
||||
match style(r.style) {
|
||||
Some(s) => {
|
||||
let fills = s.get("fillPaints").and_then(|own_fills| {
|
||||
if own_fills.list().iter().any(paint_bound) {
|
||||
return Some(own_fills);
|
||||
}
|
||||
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
|
||||
.and_then(|id| self.nodes.get(&id).copied())
|
||||
.filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
|
||||
match linked_style {
|
||||
Some(style) => style.get("fillPaints"),
|
||||
None => Some(own_fills),
|
||||
}
|
||||
});
|
||||
if let Some(fills) = fills {
|
||||
if let Kv::Array(source_paints) = fills {
|
||||
let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| {
|
||||
cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len())
|
||||
});
|
||||
let oversized_stops =
|
||||
source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256);
|
||||
if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000
|
||||
{
|
||||
self.warn("vector region paint data exceeded import limits and was skipped");
|
||||
return region;
|
||||
}
|
||||
self.region_paint_elements += paint_cost;
|
||||
let paints: Vec<Value> = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
|
||||
if paints.len() != source_paints.len() {
|
||||
self.warn("invalid vector region paints were skipped");
|
||||
}
|
||||
if paints.iter().any(has_bound_variables) {
|
||||
self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out");
|
||||
}
|
||||
if s.get("styleIdForFill").is_some() {
|
||||
self.warn("linked vector region fill styles keep resolved appearance; live style links were left out");
|
||||
}
|
||||
region["_figRegionFills"] = Value::Array(paints);
|
||||
} else {
|
||||
self.warn("invalid vector region fill paint list; layer fill used");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => self.warn("vector region references a missing fill style; layer fill used"),
|
||||
}
|
||||
}
|
||||
region
|
||||
})
|
||||
.collect();
|
||||
// An absent layer join is the source default.
|
||||
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
|
||||
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
|
||||
@@ -573,9 +651,6 @@ impl<'a> Mapper<'a> {
|
||||
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
|
||||
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
|
||||
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
|
||||
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
|
||||
self.warn("filled areas of a vector with their own fill use the layer's fill");
|
||||
}
|
||||
if unresolved_style {
|
||||
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
|
||||
}
|
||||
@@ -604,42 +679,18 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
|
||||
}
|
||||
// Every instance of a main shows the same network: it is written once and shared.
|
||||
let mut entry = json!({});
|
||||
entry["vertices"] = Value::Array(vertices);
|
||||
entry["segments"] = Value::Array(segments);
|
||||
entry["regions"] = Value::Array(regions.clone());
|
||||
if regions.iter().any(|r| r.get("_figRegionFills").is_some()) {
|
||||
o.insert("vectorNetwork".into(), entry);
|
||||
return true;
|
||||
}
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
|
||||
let id = match self.network_ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
|
||||
out["join"] = json!(join.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> = net
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
|
||||
.collect();
|
||||
let regions: Vec<Value> =
|
||||
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
|
||||
let mut entry = json!({});
|
||||
entry["vertices"] = Value::Array(vertices);
|
||||
entry["segments"] = Value::Array(segments);
|
||||
entry["regions"] = Value::Array(regions);
|
||||
self.networks.push(crate::rest::network(&entry));
|
||||
self.network_ids.insert(key, self.networks.len() - 1);
|
||||
self.networks.len() - 1
|
||||
|
||||
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
Some(Value::Object(o))
|
||||
}
|
||||
|
||||
pub(super) fn has_bound_variables(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables),
|
||||
Value::Array(values) => values.iter().any(has_bound_variables),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST
|
||||
/// `boundVariables`: `fields` pairs the format's field with the REST name.
|
||||
pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
|
||||
|
||||
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec<u8> {
|
||||
canvas(&schema(), &tail_message())
|
||||
}
|
||||
|
||||
fn region_canvas(fill_paints: Option<Vec<Kv<'static>>>, style_id: u32) -> Vec<u8> {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
let vector_data = field_mut(wave, "vectorData").unwrap();
|
||||
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
|
||||
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
|
||||
if let Some(fill_paints) = fill_paints {
|
||||
fields.push(("fillPaints", arr(fill_paints)));
|
||||
}
|
||||
let mut network = network::decode(&square_blob()).unwrap();
|
||||
network.regions[0].style = style_id;
|
||||
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
|
||||
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
|
||||
*bytes = Kv::Bytes(network::encode(&network));
|
||||
canvas(&schema(), &message)
|
||||
}
|
||||
|
||||
fn region_canvas_with_link(own: Option<Kv<'static>>, link_target: Option<u64>, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
|
||||
}
|
||||
|
||||
fn region_canvas_with_typed_link(own: Option<Kv<'static>>, link_target: Option<u64>, style_type: &'static str, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
let vector_data = field_mut(wave, "vectorData").unwrap();
|
||||
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
|
||||
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
|
||||
if let Some(own) = own {
|
||||
fields.push(("fillPaints", arr(vec![own])));
|
||||
}
|
||||
if let Some(target) = link_target {
|
||||
fields.push(("styleIdForFill", o(vec![("guid", g(target))])));
|
||||
}
|
||||
nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e(style_type)), ("fillPaints", arr(vec![linked_fill]))]));
|
||||
let mut network = network::decode(&square_blob()).unwrap();
|
||||
network.regions[0].style = 1;
|
||||
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
|
||||
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
|
||||
canvas(&schema(), &message)
|
||||
}
|
||||
|
||||
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> {
|
||||
if let Kv::Object(fields) = value {
|
||||
return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_fills_import_from_style_table() {
|
||||
let solid = o(vec![
|
||||
("type", e("SOLID")),
|
||||
("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(0.5))])),
|
||||
("opacity", f(0.5)),
|
||||
("visible", Kv::Bool(false)),
|
||||
]);
|
||||
let gradient = o(vec![
|
||||
("type", e("GRADIENT_LINEAR")),
|
||||
(
|
||||
"stops",
|
||||
arr(vec![
|
||||
o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]),
|
||||
o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(0.25))]))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let image_hash = vec![0x12, 0x34, 0x56];
|
||||
let image = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(image_hash.clone()))])), ("imageScaleMode", e("FILL"))]);
|
||||
let wire = region_canvas(Some(vec![solid, gradient, image]), 1);
|
||||
let (doc, warnings) = import(&wire, "Region fills").unwrap();
|
||||
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
|
||||
assert_eq!((color.r, color.g, color.b, fills[0].opacity, fills[0].visible), (0.2, 0.4, 0.8, 0.25, false));
|
||||
assert!(matches!(&fills[1].kind, figcraft_doc::color::PaintKind::Gradient(g) if g.stops.len() == 2 && g.stops[1].color.a == 0.25));
|
||||
let figcraft_doc::color::PaintKind::Image(imported_image) = &fills[2].kind else { panic!("{fills:?}") };
|
||||
let image_key = imported_image.image.clone();
|
||||
assert!(doc.images.contains_key(&image_key), "image reference must retain its image placeholder");
|
||||
assert!(!warnings.iter().any(|warning| warning.contains("their own fill use the layer's fill")), "{warnings:?}");
|
||||
|
||||
let saved = crate::save_bytes(&doc, None).unwrap();
|
||||
let (reopened, _) = crate::load_bytes(&saved).unwrap();
|
||||
assert_eq!(network_of(&reopened, "Wave").regions[0].fills, Some(fills.clone()));
|
||||
assert!(reopened.images.contains_key(&image_key), "image reference must survive native save/reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_missing_and_empty_paints_remain_distinct() {
|
||||
let (inherited, _) = import(®ion_canvas(None, 1), "Inherited region").unwrap();
|
||||
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
|
||||
let (empty, _) = import(®ion_canvas(Some(Vec::new()), 1), "Empty region").unwrap();
|
||||
assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
|
||||
let linked = solid(0.9, 0.1, 0.2);
|
||||
let own = solid(0.1, 0.2, 0.9);
|
||||
let wire = region_canvas_with_link(Some(own), Some(50), linked);
|
||||
let (doc, _) = import(&wire, "Linked region style").unwrap();
|
||||
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
|
||||
assert_eq!((color.r, color.g, color.b), (0.9, 0.1, 0.2));
|
||||
assert!(doc.nodes().all(|node| node.name != "Linked Fill"), "style lookup must not add a style as a layer");
|
||||
let archive = Archive::open(&wire).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let (rest, _, _) = map::to_rest(&message, "Linked region style", crate::rest::MAX_NODES);
|
||||
assert!(rest.get("styles").is_none(), "regional link must not populate the layer styles map");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_variable_bound_own_fill_wins_over_linked_style() {
|
||||
let own =
|
||||
o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.1)), ("g", f(0.2)), ("b", f(0.9)), ("a", f(1.0))])), ("colorVar", alias_data(41))]);
|
||||
let (doc, _) = import(®ion_canvas_with_link(Some(own), Some(50), solid(0.9, 0.1, 0.2)), "Bound region style").unwrap();
|
||||
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
|
||||
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() {
|
||||
let (inherited, _) = import(®ion_canvas_with_link(None, Some(50), solid(0.9, 0.1, 0.2)), "Inherited linked region").unwrap();
|
||||
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
|
||||
|
||||
let own = solid(0.1, 0.2, 0.9);
|
||||
let (fallback, _) = import(®ion_canvas_with_link(Some(own), Some(999), solid(0.9, 0.1, 0.2)), "Unresolved linked region").unwrap();
|
||||
let fills = network_of(&fallback, "Wave").regions[0].fills.as_ref().unwrap();
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
|
||||
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_link_to_non_fill_style_keeps_own_fill() {
|
||||
let own = solid(0.1, 0.2, 0.9);
|
||||
let (doc, _) = import(®ion_canvas_with_typed_link(Some(own), Some(50), "TEXT", solid(0.9, 0.1, 0.2)), "Text-linked region").unwrap();
|
||||
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
|
||||
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_paint_budget_covers_the_whole_file() {
|
||||
// 190 gradients of 255 stops: 48,640 elements per layer, so four layers fit and a fifth doesn't.
|
||||
let stop = o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]);
|
||||
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(vec![stop; 255]))]);
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
let vector_data = field_mut(wave, "vectorData").unwrap();
|
||||
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
|
||||
let Kv::Object(style_fields) = styles.first_mut().unwrap() else { panic!() };
|
||||
style_fields.push(("fillPaints", arr(vec![gradient; 190])));
|
||||
let wave = wave.clone();
|
||||
for (index, position) in ["w", "x", "y", "z"].into_iter().enumerate() {
|
||||
let mut copy = wave.clone();
|
||||
let Kv::Object(fields) = &mut copy else { panic!() };
|
||||
for (key, value) in fields.iter_mut() {
|
||||
match *key {
|
||||
"guid" => *value = g(60 + index as u64),
|
||||
"name" => *value = Kv::Str(format!("Wave {index}")),
|
||||
"parentIndex" => *value = o(vec![("guid", g(1)), ("position", s(position))]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
nodes.push(copy);
|
||||
}
|
||||
let mut network = network::decode(&square_blob()).unwrap();
|
||||
network.regions[0].style = 1;
|
||||
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
|
||||
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
|
||||
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Region paint budget").unwrap();
|
||||
let painted = ["Wave", "Wave 0", "Wave 1", "Wave 2", "Wave 3"].iter().filter(|name| network_of(&doc, name).regions[0].fills.is_some()).count();
|
||||
assert_eq!(painted, 4);
|
||||
assert!(warnings.iter().any(|warning| warning.contains("region paint data exceeded import limits")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_regions_keep_distinct_solid_paints() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
let vector_data = field_mut(wave, "vectorData").unwrap();
|
||||
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
|
||||
let Kv::Object(first_style) = styles.first_mut().unwrap() else { panic!() };
|
||||
first_style.push(("fillPaints", arr(vec![solid(0.1, 0.2, 0.9)])));
|
||||
let Kv::Object(second_style) = o(vec![("styleID", u(2)), ("fillPaints", arr(vec![solid(0.9, 0.1, 0.2)]))]) else { panic!() };
|
||||
styles.push(Kv::Object(second_style));
|
||||
let mut network = network::decode(&square_blob()).unwrap();
|
||||
network.regions[0].style = 1;
|
||||
network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() });
|
||||
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
|
||||
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
|
||||
*bytes = Kv::Bytes(network::encode(&network));
|
||||
|
||||
let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap();
|
||||
let regions = &network_of(&doc, "Wave").regions;
|
||||
assert_eq!(regions.len(), 2);
|
||||
let color = |index: usize| match ®ions[index].fills.as_ref().unwrap()[0].kind {
|
||||
figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b),
|
||||
paint => panic!("{paint:?}"),
|
||||
};
|
||||
assert_eq!(color(0), (0.1, 0.2, 0.9));
|
||||
assert_eq!(color(1), (0.9, 0.1, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_gradient_limit_warns_instead_of_silently_truncating() {
|
||||
for count in [256, 257] {
|
||||
let stops = (0..count)
|
||||
.map(|i| {
|
||||
let value = if i + 1 == count { 1.0 } else { 0.0 };
|
||||
o(vec![
|
||||
("position", f(i as f64 / (count - 1) as f64)),
|
||||
("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]);
|
||||
let (doc, warnings) = import(®ion_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap();
|
||||
let fills = &network_of(&doc, "Wave").regions[0].fills;
|
||||
if count == 256 {
|
||||
let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() };
|
||||
assert_eq!(g.stops.len(), 256);
|
||||
assert_eq!(g.stops.last().unwrap().color.r, 1.0);
|
||||
assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded")));
|
||||
} else {
|
||||
assert!(fills.is_none());
|
||||
assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_invalid_paints_and_style_refs_warn() {
|
||||
let malformed = o(vec![("opacity", f(0.5))]);
|
||||
let unsupported = o(vec![("type", e("EMOJI"))]);
|
||||
let (doc, warnings) = import(®ion_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap();
|
||||
assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new()));
|
||||
assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}");
|
||||
|
||||
let (doc, warnings) = import(®ion_canvas(None, 9), "Missing region style").unwrap();
|
||||
assert_eq!(network_of(&doc, "Wave").regions[0].fills, None);
|
||||
assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}");
|
||||
}
|
||||
|
||||
fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork {
|
||||
match &named(doc, name).kind {
|
||||
NodeKind::Vector { network } => network,
|
||||
|
||||
@@ -13,7 +13,8 @@ use super::*;
|
||||
/// Message field ids count from 1; enum values from 0.
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE
|
||||
enum PaintType SOLID GRADIENT_LINEAR IMAGE
|
||||
enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI
|
||||
enum ImageScaleMode STRETCH FIT FILL TILE
|
||||
enum WindingRule NONZERO ODD
|
||||
enum StyleType NONE FILL TEXT
|
||||
enum StackMode NONE HORIZONTAL VERTICAL
|
||||
@@ -51,7 +52,7 @@ message ConditionalActions Action[] actions, VariableData condition
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
|
||||
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
|
||||
message VariableData AnyValue value, DataType dataType
|
||||
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
|
||||
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
|
||||
message ParentIndex GUID guid, string position
|
||||
message FontName string family, string style, string postscript
|
||||
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
|
||||
|
||||
@@ -738,9 +738,30 @@ impl<'a> Ctx<'a> {
|
||||
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
|
||||
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
|
||||
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
|
||||
let net = match j.get("vectorNetworkRef") {
|
||||
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
|
||||
None => j.get("vectorNetwork").map(network),
|
||||
let net = match j.get("vectorNetwork") {
|
||||
Some(net) => Some(network(net).map(|mut network| {
|
||||
// Private `.fig` mapper handoff only. Public REST `vectorNetwork` remains
|
||||
// native network JSON; it never consumes the private mapper paint fields.
|
||||
if self.fig_sources.is_some()
|
||||
&& let Some(regions) = net.get("regions").and_then(Value::as_array)
|
||||
{
|
||||
for (region, source) in network.regions.iter_mut().zip(regions) {
|
||||
let Some(fills) = source.get("_figRegionFills") else { continue };
|
||||
let Some(list) = fills.as_array() else {
|
||||
self.warn("invalid vector region paints were skipped");
|
||||
continue;
|
||||
};
|
||||
let before = list.len();
|
||||
let wrapper = serde_json::json!({"fills": fills});
|
||||
region.fills = props::paints(self, &wrapper, "fills");
|
||||
if region.fills.as_ref().is_some_and(|parsed| parsed.len() < before) {
|
||||
self.warn("invalid vector region paints were skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
network
|
||||
})),
|
||||
None => j.get("vectorNetworkRef").map(|r| r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
|
||||
};
|
||||
if let Some(net) = net {
|
||||
match net {
|
||||
@@ -1391,6 +1412,11 @@ pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
|
||||
if !fits {
|
||||
return None;
|
||||
}
|
||||
// Region paints come only from the validated `.fig` mapper handoff, never from public JSON.
|
||||
for r in &mut net.regions {
|
||||
r.fills = None;
|
||||
r.paint_space = None;
|
||||
}
|
||||
for p in &mut net.vertices {
|
||||
p.radius = p.radius.clamp(0.0, MAX_SIZE);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,24 @@ fn stroke_only_vector_shared_stroke_style_keeps_paints() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_rest_ignores_region_fills_and_private_fig_handoff() {
|
||||
let network = json!({
|
||||
"vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}],
|
||||
"segments": [{"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 2, "b": 0}],
|
||||
"regions": [{"loops": [[0, 1, 2]], "winding": "nonZero", "fills": [{"type": "solid", "color": {"r": 0, "g": 1, "b": 0}}],
|
||||
"paint_space": {"width": 10, "height": 10, "transform": [1, 0, 0, 1, 5, 5]},
|
||||
"_figRegionFills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}]
|
||||
});
|
||||
let node = json!({"id": "1:1", "name": "Vector", "type": "VECTOR", "relativeTransform": at(0.0, 0.0),
|
||||
"size": {"x": 10, "y": 10}, "vectorNetwork": network});
|
||||
let (doc, _) = import(&file(json!([node])), &no_images).unwrap();
|
||||
let vector = named(&doc, "Vector");
|
||||
let NodeKind::Vector { network } = &vector.kind else { panic!("{:#?}", vector.kind) };
|
||||
assert!(network.regions[0].fills.is_none());
|
||||
assert!(network.regions[0].paint_space.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_colour_variable_on_outlined_vector_is_kept() {
|
||||
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}});
|
||||
|
||||
+33
-11
@@ -56,10 +56,10 @@ pub const NEWEST_TESTED_VERSION: u64 = 139;
|
||||
pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again";
|
||||
/// Largest JSON member read (a real page of a few thousand layers takes 33 MB).
|
||||
const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024;
|
||||
/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a
|
||||
/// zero-filled array a thousand times), with a floor for small members.
|
||||
const MAX_JSON_RATIO: u64 = 100;
|
||||
const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024;
|
||||
/// Most bytes a member may unpack to, per compressed byte (real JSON packs 5–20 times and images
|
||||
/// barely; a zero-filled member a thousand times), with a floor for small members.
|
||||
const MAX_RATIO: u64 = 100;
|
||||
const MIN_ALLOWANCE: u64 = 16 * 1024 * 1024;
|
||||
/// Most JSON read from one file in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
|
||||
@@ -67,6 +67,11 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
|
||||
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Most image bytes read from one file in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_IMAGE_TOTAL: u64 = 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_IMAGE_TOTAL: u64 = 256 * 1024 * 1024;
|
||||
/// Most pages read from one file.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
@@ -182,6 +187,8 @@ pub(crate) struct Archive<'b> {
|
||||
/// Problems met while reading (pages skipped).
|
||||
pub warnings: Vec<String>,
|
||||
zip: zip::ZipArchive<std::io::Cursor<&'b [u8]>>,
|
||||
/// Image bytes read so far.
|
||||
image_total: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
@@ -234,7 +241,7 @@ impl<'b> Archive<'b> {
|
||||
warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));
|
||||
}
|
||||
let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
Ok(Archive { document, version, pages, warnings, zip: z })
|
||||
Ok(Archive { document, version, pages, warnings, zip: z, image_total: std::cell::Cell::new(0) })
|
||||
}
|
||||
|
||||
/// The bytes of the image a layer references (`images/<name>`).
|
||||
@@ -245,24 +252,39 @@ impl<'b> Archive<'b> {
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
let mut z = self.zip.clone();
|
||||
crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok()
|
||||
let mut total = self.image_total.get();
|
||||
let data = capped_member(&mut z, &format!("images/{name}"), MAX_IMAGE, &mut total, MAX_IMAGE_TOTAL).ok();
|
||||
self.image_total.set(total);
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than
|
||||
/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of
|
||||
/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far).
|
||||
fn json_member(z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, FormatError> {
|
||||
/// Member `name`, refused before unpacking when it would be larger than `limit`, than
|
||||
/// [`MAX_RATIO`] times what it takes in the archive (past a floor), or than what is left of
|
||||
/// `max_total` (`total`: read so far, counted up).
|
||||
fn capped_member(
|
||||
z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
limit: u64,
|
||||
total: &mut u64,
|
||||
max_total: u64,
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
let (size, packed) = {
|
||||
let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?;
|
||||
(f.size(), f.compressed_size())
|
||||
};
|
||||
let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total));
|
||||
let allowed = limit.min(packed.saturating_mul(MAX_RATIO).max(MIN_ALLOWANCE)).min(max_total.saturating_sub(*total));
|
||||
if size > allowed {
|
||||
return Err(corrupt(format!("{name} is too large")));
|
||||
}
|
||||
let data = crate::read_member(z, name, allowed)?;
|
||||
*total += data.len() as u64;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// JSON member `name`, read by [`capped_member`] within the file's [`MAX_JSON_TOTAL`].
|
||||
fn json_member(z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, FormatError> {
|
||||
let data = capped_member(z, name, limit, total, MAX_JSON_TOTAL)?;
|
||||
serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -
|
||||
})
|
||||
.collect();
|
||||
let winding = winding(l);
|
||||
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
|
||||
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() };
|
||||
Some(VectorNetwork { vertices, segments, regions })
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option<VectorN
|
||||
return None;
|
||||
}
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding });
|
||||
out.regions.push(Region { loops, winding, ..Default::default() });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,20 @@ fn oval_slice_and_bitmap() {
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_bombs_are_refused() {
|
||||
// A valid 2×2 PNG followed by 40 MB of zeros packs to a few dozen kilobytes.
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png.resize(png.len() + 40 * 1024 * 1024, 0);
|
||||
let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0);
|
||||
bitmap["_class"] = json!("bitmap");
|
||||
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/bomb.png"});
|
||||
let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![bitmap])], &[("images/bomb.png", &png)]);
|
||||
let total: usize = doc.images.values().map(|i| i.bytes.len()).sum();
|
||||
assert!(total < 1024 * 1024, "{total} image bytes kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_class_is_a_warning() {
|
||||
let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call, answered either by an in-process
|
||||
//! engine session ([`crate::Headless`]) or by a running FigCraft app ([`Attached`]).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -76,17 +76,23 @@ pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
|
||||
base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| format!("bad base64 from the app: {e}"))
|
||||
}
|
||||
|
||||
/// The longest reply line we read from the app (512 MiB). The app's window captures alone reach
|
||||
/// 256 MiB as base64 (192 MiB PNGs); anything longer is refused without reading the rest.
|
||||
const MAX_REPLY: usize = 512 * 1024 * 1024;
|
||||
|
||||
/// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`).
|
||||
pub struct Attached {
|
||||
addr: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
/// [`MAX_REPLY`]; smaller in tests.
|
||||
max_reply: usize,
|
||||
}
|
||||
|
||||
impl Attached {
|
||||
/// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening.
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, max_reply: MAX_REPLY };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
@@ -133,17 +139,25 @@ impl Attached {
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
let max_reply = self.max_reply;
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app"));
|
||||
};
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut reply = String::new();
|
||||
if reader.read_line(&mut reply)? == 0 {
|
||||
let mut reply = Vec::new();
|
||||
if reader.take((max_reply + 1) as u64).read_until(b'\n', &mut reply)? == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
if reply.last() == Some(&b'\n') {
|
||||
reply.pop();
|
||||
}
|
||||
if reply.len() > max_reply {
|
||||
let mib = max_reply / (1024 * 1024);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("reply larger than {mib} MiB")));
|
||||
}
|
||||
String::from_utf8(reply).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,3 +195,7 @@ impl Backend for Attached {
|
||||
format!("attached to the FigCraft app at {}", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "backend_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The attached backend reads the app's replies with a bound (#1113).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Attached, Backend};
|
||||
|
||||
/// A fake app that takes one connection per reply, reads one request on it and answers with the
|
||||
/// reply. It keeps the connections open until [`finish`] (so an unterminated reply never reaches
|
||||
/// EOF), then returns how many requests it read.
|
||||
fn fake_app(replies: Vec<Vec<u8>>) -> (u16, mpsc::Sender<()>, std::thread::JoinHandle<usize>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (done, wait) = mpsc::channel::<()>();
|
||||
let app = std::thread::spawn(move || {
|
||||
let mut open = Vec::new();
|
||||
for reply in &replies {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).unwrap();
|
||||
out.write_all(reply).unwrap();
|
||||
out.flush().unwrap();
|
||||
open.push(out);
|
||||
}
|
||||
wait.recv_timeout(Duration::from_secs(30)).ok();
|
||||
open.len()
|
||||
});
|
||||
(port, done, app)
|
||||
}
|
||||
|
||||
/// Let the fake app close its connections, and check it read `requests` requests.
|
||||
fn finish(done: mpsc::Sender<()>, app: std::thread::JoinHandle<usize>, requests: usize) {
|
||||
drop(done);
|
||||
assert_eq!(app.join().unwrap(), requests);
|
||||
}
|
||||
|
||||
fn ok_reply(text: &str) -> Vec<u8> {
|
||||
format!("{}\n", json!({"id": 1, "ok": true, "result": text})).into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_reply_is_refused_and_the_next_call_reconnects() {
|
||||
let (port, done, fake) = fake_app(vec![vec![b'x'; 200], ok_reply("fine")]);
|
||||
let mut app = Attached::port(port).unwrap();
|
||||
app.max_reply = 64;
|
||||
let err = app.call("engine.commands", json!({})).unwrap_err();
|
||||
assert!(err.contains("reply larger than"), "{err}");
|
||||
assert!(err.contains("may have completed"), "{err}");
|
||||
assert!(app.conn.is_none());
|
||||
assert_eq!(app.call("engine.commands", json!({})).unwrap(), json!("fine"));
|
||||
finish(done, fake, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_at_the_limit_is_read() {
|
||||
let reply = ok_reply("at the limit");
|
||||
let (port, done, fake) = fake_app(vec![reply.clone()]);
|
||||
let mut app = Attached::port(port).unwrap();
|
||||
app.max_reply = reply.len() - 1; // the newline doesn't count
|
||||
assert_eq!(app.call("engine.commands", json!({})).unwrap(), json!("at the limit"));
|
||||
finish(done, fake, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_that_is_not_utf8_is_an_error() {
|
||||
let (port, done, fake) = fake_app(vec![b"{\"ok\": true, \"result\": \"\xff\"}\n".to_vec()]);
|
||||
let mut app = Attached::port(port).unwrap();
|
||||
let err = app.call("engine.commands", json!({})).unwrap_err();
|
||||
assert!(err.contains("failed"), "{err}");
|
||||
assert!(app.conn.is_none());
|
||||
finish(done, fake, 1);
|
||||
}
|
||||
@@ -95,6 +95,13 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
|
||||
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
|
||||
}
|
||||
|
||||
/// Normalize one path using its fill rule. Unlike [`faces`], this emits every contour without a
|
||||
/// face-count cap, so render silhouettes preserve large imported networks.
|
||||
pub fn simplify_with_rule(path: &BezPath, even_odd: bool) -> Result<BezPath, PathOpsError> {
|
||||
let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero };
|
||||
binary_by(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union)
|
||||
}
|
||||
|
||||
/// Combine `acc` (the operands so far) with the next operand `p` by `op`.
|
||||
pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result<BezPath, PathOpsError> {
|
||||
let op = match op {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! agreement with a uniform operation, and single steps.
|
||||
|
||||
use figcraft_doc::BoolOp;
|
||||
use figcraft_geom::Rect;
|
||||
use figcraft_geom::kurbo::{BezPath, Shape};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::{boolean, boolean_ops, boolean_step};
|
||||
use crate::{boolean, boolean_ops, boolean_step, simplify_with_rule};
|
||||
|
||||
fn sq(x: f64, y: f64, s: f64) -> BezPath {
|
||||
Rect::new(x, y, x + s, y + s).to_path(0.1)
|
||||
@@ -81,3 +81,20 @@ fn single_steps_and_edge_cases() {
|
||||
bad.line_to((1.0, 1.0));
|
||||
assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_rule_normalization_keeps_even_odd_holes_and_more_than_4096_contours() {
|
||||
let mut ring = sq(0.0, 0.0, 20.0);
|
||||
ring.extend(sq(5.0, 5.0, 10.0).elements().iter().copied());
|
||||
let ring = simplify_with_rule(&ring, true).unwrap();
|
||||
assert!(ring.winding(Point::new(2.0, 2.0)) != 0);
|
||||
assert_eq!(ring.winding(Point::new(10.0, 10.0)), 0, "same-direction inner contour remains a hole");
|
||||
|
||||
let mut many = BezPath::new();
|
||||
for i in 0..4097 {
|
||||
many.extend(sq(i as f64 * 2.0, 0.0, 1.0).elements().iter().copied());
|
||||
}
|
||||
let all = simplify_with_rule(&many, false).unwrap();
|
||||
assert!(all.winding(Point::new(8192.5, 0.5)) != 0, "last contour survives beyond the old 4096-face cap");
|
||||
assert!((area(&all) - 4097.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
+27
-2
@@ -329,8 +329,33 @@ impl<'a> Exporter<'a> {
|
||||
if let Some((path, r)) = &outline
|
||||
&& closed
|
||||
{
|
||||
for p in &n.fills {
|
||||
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
|
||||
if let NodeKind::Vector { network } = &n.kind
|
||||
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
|
||||
{
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
for (i, region) in net.regions.iter().enumerate() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if let Some(mut region_path) = net.region_path(i) {
|
||||
let (pw, ph, frame) = region.paint_frame(w, h);
|
||||
region_path.apply_affine(frame.inverse());
|
||||
let fill_rule =
|
||||
if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
for p in paints {
|
||||
self.fill(
|
||||
s,
|
||||
®ion_path,
|
||||
fill_rule,
|
||||
p,
|
||||
PaintFrame { size: PaintSize { width: pw, height: ph }, transform: xf * frame },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for p in &n.fills {
|
||||
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.place_inner(s, inner);
|
||||
|
||||
+32
-1
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData};
|
||||
use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, Region, Segment, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
|
||||
|
||||
pub(crate) fn page_count(pdf: &[u8]) -> usize {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
@@ -51,6 +51,37 @@ pub(crate) fn has_inter() -> bool {
|
||||
figcraft_text::find_face("Inter", "Regular").is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_vector_region_paints_emit_independent_colors() {
|
||||
let mut d = Document::new("regions");
|
||||
let p = d.first_page().unwrap();
|
||||
let network = VectorNetwork {
|
||||
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Vertex::new(x, y))
|
||||
.collect(),
|
||||
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
|
||||
regions: vec![
|
||||
Region {
|
||||
loops: vec![vec![0, 1, 2, 3]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
|
||||
let content = inflated(&export_pdf(&d, &[id], &PdfOptions::default()).unwrap());
|
||||
assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}");
|
||||
}
|
||||
|
||||
/// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text.
|
||||
pub(crate) fn sample_doc() -> (Document, Vec<NodeId>) {
|
||||
let mut d = Document::new("sample");
|
||||
|
||||
@@ -326,6 +326,27 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
|
||||
Some((p, FillRule::Winding))
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let mut areas = BezPath::new();
|
||||
for (i, region) in net.regions.iter().enumerate() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if !paints.iter().any(|p| p.visible) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = net.region_path(i) else { continue };
|
||||
let even_odd = region.winding == figcraft_doc::WindingRule::EvenOdd;
|
||||
if let Ok(normalized) = figcraft_pathops::simplify_with_rule(&path, even_odd) {
|
||||
areas.extend(normalized.elements().iter().copied());
|
||||
} else {
|
||||
// Invalid geometry still gets the renderer's best-effort path.
|
||||
areas.extend(path.elements().iter().copied());
|
||||
}
|
||||
}
|
||||
let silhouette = figcraft_pathops::simplify(&areas).unwrap_or(areas);
|
||||
return Some((silhouette, FillRule::Winding));
|
||||
}
|
||||
let rule =
|
||||
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
|
||||
Some((network.fill_path(), rule))
|
||||
@@ -702,6 +723,31 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
|
||||
/// What paints over a node's fills, under its children and strokes (its inner shadows).
|
||||
type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>;
|
||||
|
||||
/// A vector's network when any of its regions has paints or a paint frame of its own.
|
||||
fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => Some(network),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its
|
||||
/// own paint frame.
|
||||
fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
for (i, region) in net.regions.iter().enumerate() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if let Some(mut path) = net.region_path(i) {
|
||||
let (pw, ph, frame) = region.paint_frame(n.width, n.height);
|
||||
path.apply_affine(frame.inverse());
|
||||
let Some(tp) = ts_path(&path) else { continue };
|
||||
let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding };
|
||||
fill_with(pix, &tp, paints, pw, ph, xf * frame, rule, ctx, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects.
|
||||
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) {
|
||||
let doc = ctx.doc;
|
||||
@@ -783,14 +829,18 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
||||
let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
|
||||
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
|
||||
let styled_network = region_painted(n);
|
||||
// The outline fills, clips children to rounded corners and strokes booleans.
|
||||
let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
|
||||
if !splatted
|
||||
&& let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
&& let Some(tp) = ts_path(path)
|
||||
{
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
|
||||
let outline =
|
||||
if styled_network.is_some() || (splatted && !(clips && n.corners.uniform() != Some(0.0))) { None } else { node_outline(doc, n) };
|
||||
if !splatted && closed {
|
||||
if let Some(network) = styled_network {
|
||||
fill_vector_regions(ctx, pix, n, network, xf);
|
||||
} else if let Some((path, rule)) = &outline
|
||||
&& let Some(tp) = ts_path(path)
|
||||
{
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
|
||||
}
|
||||
}
|
||||
if let Some(f) = over_fills {
|
||||
f(pix);
|
||||
|
||||
@@ -77,6 +77,9 @@ pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, m
|
||||
{
|
||||
return (ok, Some(levels));
|
||||
}
|
||||
if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())) {
|
||||
return (false, Some(0));
|
||||
}
|
||||
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
|
||||
let own = match &n.kind {
|
||||
NodeKind::Text(t) => text_paints(n, t).any(image),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
use figcraft_color::{Color, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
|
||||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, Region, TextData, VectorNetwork};
|
||||
use figcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::retained_tests::assert_same;
|
||||
@@ -110,6 +110,19 @@ fn lod_is_off_unless_asked_for() {
|
||||
assert!(!Options::default().lod);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paints_disable_vector_and_ancestor_splatting() {
|
||||
let mut d = Document::new("region LOD");
|
||||
let page = d.first_page().unwrap();
|
||||
let parent = d.create(page, NodeKind::Frame, "Parent", 0.0, 0.0, 4.0, 4.0).unwrap();
|
||||
let mut network = VectorNetwork::default();
|
||||
network.regions.push(Region { loops: Vec::new(), winding: Default::default(), fills: Some(Vec::new()), ..Default::default() });
|
||||
let vector = d.create(parent, NodeKind::Vector { network }, "Styled", 0.0, 0.0, 2.0, 2.0).unwrap();
|
||||
let node = d.node(vector).unwrap();
|
||||
assert!(!lod::splattable(&d, node, 0));
|
||||
assert!(!lod::splattable(&d, d.node(parent).unwrap(), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_previews_are_close_to_exact() {
|
||||
let (d, p) = cards();
|
||||
|
||||
+172
-1
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData};
|
||||
use figcraft_doc::{Effect, EffectKind, NodeKind, Region, Segment, Shadow, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
|
||||
|
||||
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = ((y * p.width() + x) * 4) as usize;
|
||||
@@ -28,6 +28,177 @@ fn solid_rect_and_page_background() {
|
||||
assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_regions_use_their_own_paints_and_empty_regions_do_not_erase() {
|
||||
let mut d = figcraft_doc::Document::new("regions");
|
||||
let page = d.first_page().unwrap();
|
||||
let network = VectorNetwork {
|
||||
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Vertex::new(x, y))
|
||||
.collect(),
|
||||
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
|
||||
regions: vec![
|
||||
Region {
|
||||
loops: vec![vec![0, 1, 2, 3]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]), ..Default::default() },
|
||||
],
|
||||
};
|
||||
let id = d.create(page, NodeKind::Vector { network }, "V", 10.0, 10.0, 20.0, 10.0).unwrap();
|
||||
d.get_mut(id).unwrap().fills.clear();
|
||||
let pix = render_view(&d, page, &view(40, 30), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
assert_eq!(px(&pix, 15, 15), [255, 0, 0, 255]);
|
||||
assert_eq!(px(&pix, 25, 15), [0, 0, 255, 255]);
|
||||
assert_eq!(d.get(id).unwrap().fills.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paint_space_preserves_gradient_image_and_pattern_placement() {
|
||||
use figcraft_color::{ImageScaleMode, PaintKind, PatternPaint};
|
||||
use figcraft_doc::RegionPaintSpace;
|
||||
use figcraft_geom::{Affine, Rect, Shape};
|
||||
|
||||
let mut d = Document::new("paint frames");
|
||||
let page = d.first_page().unwrap();
|
||||
let tile = d.create(page, NodeKind::Rectangle, "Tile", 500.0, 500.0, 8.0, 6.0).unwrap();
|
||||
d.get_mut(tile).unwrap().fills = vec![Paint::solid(Color::from_hex("F05020").unwrap())];
|
||||
let mut image = Pixmap::new(8, 4).unwrap();
|
||||
for (i, pixel) in image.pixels_mut().iter_mut().enumerate() {
|
||||
*pixel = tiny_skia::PremultipliedColorU8::from_rgba((i % 8 * 30) as u8, (i / 8 * 60) as u8, 30, 255).unwrap();
|
||||
}
|
||||
d.images.insert(
|
||||
"region-frame-image".into(),
|
||||
figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&image).unwrap()), width: 8, height: 4 },
|
||||
);
|
||||
let mut pattern = PatternPaint::new(tile.0);
|
||||
pattern.spacing = (3.0, 4.0);
|
||||
let paints = [
|
||||
Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)),
|
||||
Paint::image("region-frame-image", ImageScaleMode::Fill),
|
||||
Paint::image("region-frame-image", ImageScaleMode::Tile),
|
||||
Paint { kind: PaintKind::Pattern(pattern), ..Paint::solid(Color::WHITE) },
|
||||
];
|
||||
let frame = Affine::translate((35.0, 25.0)) * Affine::rotate(0.3);
|
||||
let id = d.create(page, NodeKind::Rectangle, "Source", 0.0, 0.0, 60.0, 30.0).unwrap();
|
||||
for paint in paints {
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.kind = NodeKind::Rectangle;
|
||||
n.transform = frame;
|
||||
n.width = 60.0;
|
||||
n.height = 30.0;
|
||||
n.fills = vec![paint.clone()];
|
||||
let before = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
|
||||
let mut path = Rect::new(0.0, 0.0, 60.0, 30.0).to_path(0.1);
|
||||
path.apply_affine(frame);
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
for region in &mut network.regions {
|
||||
region.fills = Some(vec![paint.clone()]);
|
||||
region.paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: frame });
|
||||
}
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.kind = NodeKind::Vector { network };
|
||||
n.transform = Affine::IDENTITY;
|
||||
n.width = 120.0;
|
||||
n.height = 100.0;
|
||||
n.fills.clear();
|
||||
let after = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
|
||||
for (a, b) in before.data().iter().zip(after.data()) {
|
||||
assert!(a.abs_diff(*b) <= 1, "paint moved after reboxing: {:?}", paint.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_vector_mask_unions_region_windings_and_excludes_unpainted_regions() {
|
||||
let mut d = figcraft_doc::Document::new("region mask");
|
||||
let page = d.first_page().unwrap();
|
||||
let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 60.0, 20.0).unwrap();
|
||||
let mut network = VectorNetwork::default();
|
||||
let mut regions = Vec::new();
|
||||
for x in [0.0, 10.0, 30.0, 40.0, 50.0] {
|
||||
let v = network.vertices.len();
|
||||
network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
|
||||
let s = network.segments.len();
|
||||
network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
|
||||
let fills = match regions.len() {
|
||||
0 => Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
|
||||
1 => Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
2 => Some(vec![Paint { visible: false, ..Paint::solid(Color::from_hex("00FF00").unwrap()) }]),
|
||||
3 => None,
|
||||
_ => Some(Vec::new()),
|
||||
};
|
||||
regions.push(Region {
|
||||
loops: vec![(s..s + 4).collect()],
|
||||
winding: if regions.len() == 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
|
||||
fills,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
network.regions = regions;
|
||||
let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 60.0, 20.0).unwrap();
|
||||
{
|
||||
let n = d.get_mut(mask).unwrap();
|
||||
n.fills.clear();
|
||||
n.is_mask = true;
|
||||
n.mask_type = figcraft_doc::MaskType::Vector;
|
||||
}
|
||||
let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 60.0, 20.0).unwrap();
|
||||
d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let pix = render_view(&d, page, &view(60, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
assert_eq!(px(&pix, 5, 10), [0, 0, 0, 255], "visible left region masks");
|
||||
assert_eq!(px(&pix, 15, 10), [0, 0, 0, 255], "overlap remains in union despite mixed winding");
|
||||
assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255], "hidden region excluded from styled silhouette");
|
||||
assert_eq!(px(&pix, 45, 10), [255, 255, 255, 255], "unpainted inherited region excluded");
|
||||
assert_eq!(px(&pix, 55, 10), [255, 255, 255, 255], "explicit-empty region excluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_paint_frames_keep_overlapping_regions_in_vector_masks() {
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
|
||||
let mut d = Document::new("inherited region mask");
|
||||
let page = d.first_page().unwrap();
|
||||
let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 40.0, 20.0).unwrap();
|
||||
let mut network = VectorNetwork::default();
|
||||
for x in [0.0, 10.0] {
|
||||
let v = network.vertices.len();
|
||||
let s = network.segments.len();
|
||||
network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
|
||||
network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
|
||||
network.regions.push(Region {
|
||||
loops: vec![(s..s + 4).collect()],
|
||||
winding: WindingRule::EvenOdd,
|
||||
fills: None,
|
||||
paint_space: Some(figcraft_doc::RegionPaintSpace { width: 20.0, height: 20.0, transform: Affine::translate((x, 0.0)) }),
|
||||
});
|
||||
}
|
||||
let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 30.0, 20.0).unwrap();
|
||||
let n = d.get_mut(mask).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::BLACK)];
|
||||
n.is_mask = true;
|
||||
n.mask_type = figcraft_doc::MaskType::Vector;
|
||||
let (outline, rule) = node_outline(&d, d.node(mask).unwrap()).unwrap();
|
||||
assert_eq!(rule, FillRule::Winding);
|
||||
assert_ne!(outline.winding(Point::new(15.0, 10.0)), 0, "independent inherited regions retain their overlap");
|
||||
let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 40.0, 20.0).unwrap();
|
||||
d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let pix = render_view(&d, page, &view(40, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
for x in [5, 15, 25] {
|
||||
assert_eq!(px(&pix, x, 10), [0, 0, 0, 255]);
|
||||
}
|
||||
assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_scales() {
|
||||
let (d, p, _) = doc_with_rect(Color::BLACK);
|
||||
|
||||
@@ -718,6 +718,45 @@ fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> {
|
||||
o
|
||||
}
|
||||
|
||||
/// Length of the `<!...>` declaration at the start of `s`, skipping `>` inside quotes and an
|
||||
/// internal subset (`[ ... ]`).
|
||||
fn doctype_end(s: &str) -> Option<usize> {
|
||||
let mut quote = None;
|
||||
let mut depth = 0u32;
|
||||
let mut skip_to = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
if i < skip_to {
|
||||
continue;
|
||||
}
|
||||
if quote.is_none() && depth > 0 {
|
||||
// Comments and processing instructions in the subset may hold quotes and brackets.
|
||||
let rest = &s[i..];
|
||||
let close = if rest.starts_with("<!--") {
|
||||
Some("-->")
|
||||
} else if rest.starts_with("<?") {
|
||||
Some("?>")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(close) = close {
|
||||
let len = rest.find(close)? + close.len();
|
||||
skip_to = i + len;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match (quote, c) {
|
||||
(Some(q), _) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(None, '[') => depth += 1,
|
||||
(None, ']') => depth = depth.saturating_sub(1),
|
||||
(None, '>') if depth == 0 => return Some(i + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `text` (from the clipboard, say) is an SVG document: `<svg` after an optional byte
|
||||
/// order mark, XML declaration, comments, doctype and whitespace.
|
||||
pub fn looks_like_svg(text: &str) -> bool {
|
||||
@@ -731,7 +770,7 @@ pub fn looks_like_svg(text: &str) -> bool {
|
||||
} else if s.starts_with("<!--") {
|
||||
s.find("-->").map(|i| i + 3)
|
||||
} else if s.starts_with("<!") {
|
||||
s.find('>').map(|i| i + 1)
|
||||
doctype_end(s)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -107,3 +107,14 @@ fn layer_names_decode_id_escapes() {
|
||||
assert_eq!(layer_name("", "Group"), "Group");
|
||||
assert_eq!(layer_name("é_x41_", "Group"), "éA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctype_with_internal_subset_is_still_svg() {
|
||||
let s = "<?xml version=\"1.0\"?>\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\t<!ENTITY ns_svg \"http://www.w3.org/2000/svg\">\n]>\n<svg xmlns=\"&ns_svg;\" width=\"10\" height=\"10\"/>";
|
||||
assert!(super::looks_like_svg(s));
|
||||
let quoted = "<!DOCTYPE svg [ <!ENTITY a \"x>y]\"> ]><svg/>";
|
||||
assert!(super::looks_like_svg(quoted));
|
||||
let commented = "<!DOCTYPE svg [ <!-- don't ] > --> <!ENTITY a \"b\"> ]><svg/>";
|
||||
assert!(super::looks_like_svg(commented));
|
||||
assert!(!super::looks_like_svg("<!DOCTYPE html [ <svg/> ]><html/>"));
|
||||
}
|
||||
|
||||
+26
-5
@@ -494,11 +494,32 @@ impl W<'_> {
|
||||
} else if let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
{
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
if let NodeKind::Vector { network } = &n.kind
|
||||
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
|
||||
{
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
for (i, region) in net.regions.iter().enumerate() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
if let Some(mut region_path) = net.region_path(i) {
|
||||
let (pw, ph, frame) = region.paint_frame(w, h);
|
||||
region_path.apply_affine(frame.inverse());
|
||||
for p in paints {
|
||||
if let Some(a) = self.paint_attr(p, pw, ph) {
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf * frame), path_d(®ion_path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeKind};
|
||||
use figcraft_doc::{Document, Effect, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
|
||||
#[test]
|
||||
fn export_basic_shapes() {
|
||||
@@ -23,6 +23,38 @@ fn export_basic_shapes() {
|
||||
assert!(d2.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_vector_region_paints_independently() {
|
||||
let mut d = Document::new("regions");
|
||||
let p = d.first_page().unwrap();
|
||||
let network = VectorNetwork {
|
||||
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Vertex::new(x, y))
|
||||
.collect(),
|
||||
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
|
||||
regions: vec![
|
||||
Region {
|
||||
loops: vec![vec![0, 1, 2, 3]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("#FF0000") && svg.contains("#0000FF"), "{svg}");
|
||||
assert_eq!(svg.matches("<path ").count(), 2, "empty region emits no path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_garbage() {
|
||||
let mut d = Document::new("t");
|
||||
@@ -30,6 +62,21 @@ fn import_rejects_garbage() {
|
||||
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_gradient_export_keeps_the_original_paint_box() {
|
||||
use figcraft_color::{Gradient, GradientKind};
|
||||
use figcraft_doc::RegionPaintSpace;
|
||||
use figcraft_geom::{Affine, Rect, Shape};
|
||||
let mut d = Document::new("region gradient");
|
||||
let page = d.first_page().unwrap();
|
||||
let mut network = VectorNetwork::from_path(&Rect::new(20.0, 10.0, 80.0, 40.0).to_path(0.1));
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK))]);
|
||||
network.regions[0].paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: Affine::translate((20.0, 10.0)) });
|
||||
let id = d.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 120.0, 100.0).unwrap();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("x1=\"30\" y1=\"0\" x2=\"30\" y2=\"30\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_gradient_and_groups() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
|
||||
|
||||
@@ -1204,7 +1204,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "حل"
|
||||
"Resolve change" = "حل التغيير"
|
||||
"Resolved" = "محلول"
|
||||
"Resources" = "الموارد"
|
||||
"Responsive" = "متجاوب"
|
||||
"Restart" = "إعادة البدء"
|
||||
"Restore" = "استعادة"
|
||||
@@ -1694,7 +1693,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "تُستورد الموصلات الزاوية والمنحنية التي ليس لها مسار محفوظ كخطوط مستقيمة"
|
||||
"entries without a node type were skipped" = "تم تخطي الإدخالات التي ليس لها نوع عقدة"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "تفتح روابط figcraft:// من المتصفح أو الدردشة أو البريد هذا التطبيق وتنضم إلى الجلسة."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "تستخدم مناطق المتجه المعبأة ذات التعبئة الخاصة تعبئة الطبقة"
|
||||
"from {server}" = "من {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "تم تخطي محفزات ذراع الألعاب التي لا تحدد زرًا معروفًا واحدًا بالضبط"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"
|
||||
|
||||
@@ -1164,7 +1164,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "সমাধান করুন"
|
||||
"Resolve change" = "পরিবর্তন সমাধান করুন"
|
||||
"Resolved" = "সমাধান করা হয়েছে"
|
||||
"Resources" = "রিসোর্সসমূহ"
|
||||
"Responsive" = "রেসপনসিভ"
|
||||
"Restart" = "পুনরায় শুরু করুন"
|
||||
"Restore" = "পুনরুদ্ধার করুন"
|
||||
@@ -1638,7 +1637,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়"
|
||||
"entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "ভেক্টরের নিজস্ব ফিলযুক্ত পূর্ণ এলাকাগুলো লেয়ারের ফিল ব্যবহার করে"
|
||||
"from {server}" = "{server} থেকে"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "সঠিকভাবে একটি পরিচিত কন্ট্রোলার বোতাম ছাড়া গেমপ্যাড ট্রিগার বাদ দেওয়া হয়েছে"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে"
|
||||
|
||||
@@ -1142,7 +1142,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Vyřešit"
|
||||
"Resolve change" = "Vyřešit změnu"
|
||||
"Resolved" = "Vyřešené"
|
||||
"Resources" = "Zdroje"
|
||||
"Responsive" = "Responzivní"
|
||||
"Restart" = "Znovu spustit"
|
||||
"Restore" = "Obnovit"
|
||||
@@ -1604,7 +1603,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky"
|
||||
"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy"
|
||||
"from {server}" = "ze serveru {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost"
|
||||
|
||||
@@ -1112,7 +1112,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Erledigen"
|
||||
"Resolve change" = "Änderung erledigen"
|
||||
"Resolved" = "Erledigt"
|
||||
"Resources" = "Ressourcen"
|
||||
"Responsive" = "Responsiv"
|
||||
"Restart" = "Neu starten"
|
||||
"Restore" = "Wiederherstellen"
|
||||
@@ -1564,7 +1563,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert"
|
||||
"entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene"
|
||||
"from {server}" = "von {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspuren hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe"
|
||||
|
||||
@@ -1202,7 +1202,6 @@ name = "English"
|
||||
"Resolve" = "Resolve"
|
||||
"Resolve change" = "Resolve change"
|
||||
"Resolved" = "Resolved"
|
||||
"Resources" = "Resources"
|
||||
"Responsive" = "Responsive"
|
||||
"Restart" = "Restart"
|
||||
"Restore" = "Restore"
|
||||
@@ -1693,7 +1692,6 @@ name = "English"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines"
|
||||
"entries without a node type were skipped" = "entries without a node type were skipped"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill"
|
||||
"from {server}" = "from {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track list exceeds 1000 entries; the tracks use the default equal sizing"
|
||||
@@ -1714,12 +1712,15 @@ name = "English"
|
||||
"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored"
|
||||
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
|
||||
"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped"
|
||||
"invalid vector region fill paint list; layer fill used" = "invalid vector region fill paint list; layer fill used"
|
||||
"invalid vector region paints were skipped" = "invalid vector region paints were skipped"
|
||||
"just now" = "just now"
|
||||
"key triggers without a supported key were skipped" = "key triggers without a supported key were skipped"
|
||||
"key up triggers import as key presses" = "key up triggers import as key presses"
|
||||
"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin"
|
||||
"layers without a type were skipped" = "layers without a type were skipped"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped"
|
||||
"linked vector region fill styles keep resolved appearance; live style links were left out" = "linked vector region fill styles keep resolved appearance; live style links were left out"
|
||||
"locked" = "locked"
|
||||
"malformed image filters container was skipped" = "malformed image filters container was skipped"
|
||||
"malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped"
|
||||
@@ -1775,10 +1776,13 @@ name = "English"
|
||||
"variable modes of collections that aren't in the file were skipped" = "variable modes of collections that aren't in the file were skipped"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "variable modes of collections that aren't in the variables JSON were skipped"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "variable values that don't match their type (or composed colours) were skipped"
|
||||
"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "variable-bound vector region paints keep resolved appearance; live bindings were left out"
|
||||
"variables of unknown types were skipped" = "variables of unknown types were skipped"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped"
|
||||
"variables without readable values were skipped" = "variables without readable values were skipped"
|
||||
"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join"
|
||||
"vector region paint data exceeded import limits and was skipped" = "vector region paint data exceeded import limits and was skipped"
|
||||
"vector region references a missing fill style; layer fill used" = "vector region references a missing fill style; layer fill used"
|
||||
"vectors without geometry are empty" = "vectors without geometry are empty"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters"
|
||||
|
||||
@@ -1198,7 +1198,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver cambio"
|
||||
"Resolved" = "Resuelto"
|
||||
"Resources" = "Recursos"
|
||||
"Responsive" = "Adaptable"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
@@ -1682,7 +1681,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas"
|
||||
"entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de pistas de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado"
|
||||
|
||||
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Ratkaise"
|
||||
"Resolve change" = "Ratkaise muutos"
|
||||
"Resolved" = "Ratkaistu"
|
||||
"Resources" = "Resurssit"
|
||||
"Responsive" = "Mukautuva"
|
||||
"Restart" = "Aloita uudelleen"
|
||||
"Restore" = "Palauta"
|
||||
@@ -1643,7 +1642,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina"
|
||||
"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä"
|
||||
"from {server}" = "palvelimelta {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa"
|
||||
|
||||
@@ -1169,7 +1169,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Résoudre"
|
||||
"Resolve change" = "Résoudre la modification"
|
||||
"Resolved" = "Résolu"
|
||||
"Resources" = "Ressources"
|
||||
"Responsive" = "Adaptatif"
|
||||
"Restart" = "Recommencer"
|
||||
"Restore" = "Restaurer"
|
||||
@@ -1646,7 +1645,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites"
|
||||
"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque"
|
||||
"from {server}" = "depuis {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut"
|
||||
|
||||
@@ -1148,7 +1148,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Selesaikan"
|
||||
"Resolve change" = "Selesaikan perubahan"
|
||||
"Resolved" = "Terselesaikan"
|
||||
"Resources" = "Sumber daya"
|
||||
"Responsive" = "Responsif"
|
||||
"Restart" = "Mulai ulang"
|
||||
"Restore" = "Pulihkan"
|
||||
@@ -1608,7 +1607,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus"
|
||||
"entries without a node type were skipped" = "entri tanpa tipe node dilewati"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer"
|
||||
"from {server}" = "dari {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan"
|
||||
|
||||
@@ -1128,7 +1128,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Risolvi"
|
||||
"Resolve change" = "Risolvi la modifica"
|
||||
"Resolved" = "Risolto"
|
||||
"Resources" = "Risorse"
|
||||
"Responsive" = "Responsive"
|
||||
"Restart" = "Riavvia"
|
||||
"Restore" = "Ripristina"
|
||||
@@ -1581,7 +1580,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "i connettori ad angolo e curvi senza un tracciato salvato vengono importati come linee rette"
|
||||
"entries without a node type were skipped" = "le voci senza tipo di nodo sono state saltate"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "i link figcraft:// da un browser, una chat o un'email aprono questa app e partecipano alla sessione."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "le aree riempite di un vettore con riempimento proprio usano il riempimento del livello"
|
||||
"from {server}" = "da {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "i trigger del gamepad senza esattamente un pulsante noto del controller sono stati saltati"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "l'elenco delle tracce della griglia supera 1000 voci; le tracce usano il dimensionamento uniforme predefinito"
|
||||
|
||||
@@ -1156,7 +1156,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "解決"
|
||||
"Resolve change" = "変更を解決"
|
||||
"Resolved" = "解決済み"
|
||||
"Resources" = "リソース"
|
||||
"Responsive" = "レスポンシブ"
|
||||
"Restart" = "再起動"
|
||||
"Restore" = "復元"
|
||||
@@ -1627,7 +1626,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート"
|
||||
"entries without a node type were skipped" = "ノードタイプのない項目をスキップ"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用"
|
||||
"from {server}" = "{server} から"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのリストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用"
|
||||
|
||||
@@ -1179,7 +1179,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Resolve" = "გადაწყვეტა"
|
||||
"Resolve change" = "ცვლილების გადაწყვეტა"
|
||||
"Resolved" = "გადაწყვეტილია"
|
||||
"Resources" = "რესურსები"
|
||||
"Responsive" = "ადაპტური"
|
||||
"Restart" = "გადატვირთვა"
|
||||
"Restore" = "აღდგენა"
|
||||
@@ -1664,7 +1663,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება"
|
||||
"entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "ვექტორის შევსებული არეები საკუთარი შევსებით შრის შევსებას იყენებს"
|
||||
"from {server}" = "წყარო: {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "გეიმპადის გამშვებები, რომლებსაც ზუსტად ერთი ცნობილი კონტროლერის ღილაკი არ აქვს, გამოტოვებულია"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს"
|
||||
|
||||
@@ -1106,7 +1106,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "해결"
|
||||
"Resolve change" = "변경 사항 해결"
|
||||
"Resolved" = "해결됨"
|
||||
"Resources" = "리소스"
|
||||
"Responsive" = "반응형"
|
||||
"Restart" = "다시 시작"
|
||||
"Restore" = "복원"
|
||||
@@ -1555,7 +1554,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴"
|
||||
"entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함"
|
||||
"from {server}" = "{server}에서"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함"
|
||||
|
||||
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Oplossen"
|
||||
"Resolve change" = "Wijziging oplossen"
|
||||
"Resolved" = "Opgelost"
|
||||
"Resources" = "Bronnen"
|
||||
"Responsive" = "Responsive"
|
||||
"Restart" = "Opnieuw starten"
|
||||
"Restore" = "Terugzetten"
|
||||
@@ -1643,7 +1642,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen"
|
||||
"entries without a node type were skipped" = "items zonder node-type zijn overgeslagen"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gevulde gebieden van een vector met een eigen vulling gebruiken de vulling van de laag"
|
||||
"from {server}" = "van {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad-triggers zonder precies één bekende controllerknop zijn overgeslagen"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met rastertracks overschrijdt 1000 items; de tracks gebruiken de standaard gelijke grootte"
|
||||
|
||||
@@ -1172,7 +1172,6 @@ name = "Polski"
|
||||
"Resolve" = "Rozwiąż"
|
||||
"Resolve change" = "Rozwiąż zmianę"
|
||||
"Resolved" = "Rozwiązany"
|
||||
"Resources" = "Zasoby"
|
||||
"Responsive" = "Responsywny"
|
||||
"Restart" = "Uruchom ponownie"
|
||||
"Restore" = "Przywróć"
|
||||
@@ -1653,7 +1652,6 @@ name = "Polski"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste"
|
||||
"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy"
|
||||
"from {server}" = "z serwera {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru"
|
||||
|
||||
@@ -1105,7 +1105,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver alteração"
|
||||
"Resolved" = "Resolvido"
|
||||
"Resources" = "Recursos"
|
||||
"Responsive" = "Responsivo"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
@@ -1553,7 +1552,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas"
|
||||
"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão"
|
||||
|
||||
@@ -1172,7 +1172,6 @@ name = "Русский"
|
||||
"Resolve" = "Решить"
|
||||
"Resolve change" = "Разрешить изменение"
|
||||
"Resolved" = "Решённые"
|
||||
"Resources" = "Ресурсы"
|
||||
"Responsive" = "Адаптивно"
|
||||
"Restart" = "Начать заново"
|
||||
"Restore" = "Восстановить"
|
||||
@@ -1653,7 +1652,6 @@ name = "Русский"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые"
|
||||
"entries without a node type were skipped" = "записи без типа узла пропущены"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя"
|
||||
"from {server}" = "с {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "список треков сетки превышает 1000 записей; треки получают стандартный равный размер"
|
||||
|
||||
@@ -1199,7 +1199,6 @@ name = "Svenska"
|
||||
"Resolve" = "Lös"
|
||||
"Resolve change" = "Lös ändring"
|
||||
"Resolved" = "Löst"
|
||||
"Resources" = "Resurser"
|
||||
"Responsive" = "Responsiv"
|
||||
"Restart" = "Starta om"
|
||||
"Restore" = "Återställ"
|
||||
@@ -1689,7 +1688,6 @@ name = "Svenska"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer"
|
||||
"entries without a node type were skipped" = "poster utan nodtyp hoppades över"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning"
|
||||
"from {server}" = "från {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår"
|
||||
|
||||
@@ -1161,7 +1161,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "แก้ไขแล้ว"
|
||||
"Resolve change" = "แก้ไขการเปลี่ยนแปลง"
|
||||
"Resolved" = "แก้ไขแล้ว"
|
||||
"Resources" = "แหล่งข้อมูล"
|
||||
"Responsive" = "รีสปอนซีฟ"
|
||||
"Restart" = "เริ่มใหม่"
|
||||
"Restore" = "กู้คืน"
|
||||
@@ -1632,7 +1631,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "ตัวเชื่อมแบบหักมุมและโค้งที่ไม่มีเส้นทางที่บันทึกไว้นำเข้าเป็นเส้นตรง"
|
||||
"entries without a node type were skipped" = "ข้ามรายการที่ไม่มีชนิดโหนด"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "ลิงก์ figcraft:// จากเบราว์เซอร์ แชต หรืออีเมลจะเปิดแอปนี้และเข้าร่วมเซสชัน"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "พื้นที่เติมสีของเวกเตอร์ที่มีสีเติมของตัวเองใช้สีเติมของเลเยอร์"
|
||||
"from {server}" = "จาก {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "ข้ามทริกเกอร์จอยเกมที่ไม่ได้ระบุปุ่มควบคุมที่รู้จักเพียงปุ่มเดียว"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "รายการแทร็กกริดเกิน 1000 รายการ: แทร็กใช้ขนาดเท่ากันตามค่าเริ่มต้น"
|
||||
|
||||
@@ -1146,7 +1146,6 @@ name = "Türkçe"
|
||||
"Resolve" = "Çöz"
|
||||
"Resolve change" = "Değişikliği çöz"
|
||||
"Resolved" = "Çözüldü"
|
||||
"Resources" = "Kaynaklar"
|
||||
"Responsive" = "Duyarlı"
|
||||
"Restart" = "Yeniden başlat"
|
||||
"Restore" = "Geri yükle"
|
||||
@@ -1608,7 +1607,6 @@ name = "Türkçe"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır"
|
||||
"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır"
|
||||
"from {server}" = "kaynak: {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır"
|
||||
|
||||
@@ -1129,7 +1129,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "Giải quyết"
|
||||
"Resolve change" = "Giải quyết thay đổi"
|
||||
"Resolved" = "Đã giải quyết"
|
||||
"Resources" = "Tài nguyên"
|
||||
"Responsive" = "Đáp ứng"
|
||||
"Restart" = "Bắt đầu lại"
|
||||
"Restore" = "Khôi phục"
|
||||
@@ -1582,7 +1581,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng"
|
||||
"entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "các vùng tô riêng trong một vector sẽ dùng màu tô của lớp"
|
||||
"from {server}" = "từ {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "đã bỏ qua các kích hoạt tay cầm không có đúng một nút điều khiển đã biết"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định"
|
||||
|
||||
@@ -1114,7 +1114,6 @@ status = "draft — needs native review"
|
||||
"Resolve" = "解决"
|
||||
"Resolve change" = "解决更改"
|
||||
"Resolved" = "已解决"
|
||||
"Resources" = "资源"
|
||||
"Responsive" = "响应式"
|
||||
"Restart" = "重新开始"
|
||||
"Restore" = "恢复"
|
||||
@@ -1563,7 +1562,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线"
|
||||
"entries without a node type were skipped" = "已跳过没有节点类型的条目"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充"
|
||||
"from {server}" = "来自 {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道列表超过 1000 个条目;轨道使用默认的等分尺寸"
|
||||
|
||||
@@ -27,7 +27,7 @@ fn board() -> Ui {
|
||||
/// The templates button: left of the Design panel's button at the board toolbar's right end.
|
||||
fn templates_button(u: &Ui) -> Pos2 {
|
||||
let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app));
|
||||
pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y)
|
||||
pos2(p.max.x - 8.0 - 32.0 - 8.0 - 16.0, p.center().y)
|
||||
}
|
||||
|
||||
fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize {
|
||||
|
||||
@@ -39,10 +39,10 @@ fn pill(u: &Ui) -> Rect {
|
||||
}
|
||||
|
||||
/// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the
|
||||
/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp,
|
||||
/// groups with flyouts, 8 between): move, ink, sticky, shapes, connector, text, section, stamp,
|
||||
/// comment.
|
||||
fn group_x(u: &Ui, i: usize) -> f32 {
|
||||
const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0];
|
||||
const WIDTHS: [f32; 9] = [58.0, 58.0, 58.0, 58.0, 40.0, 40.0, 40.0, 58.0, 40.0];
|
||||
pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::<f32>()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed
|
||||
//! sidebar and from the version history too.
|
||||
//! Shift+I opens the Assets panel (#710), from a collapsed sidebar and from the version history
|
||||
//! too — and the toolbar has no Resources button for it any more (the left rail's Assets tab,
|
||||
//! Shift+I, Alt+2 and View ▸ Panels ▸ Assets are the ways in).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::LeftTab;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn shift_i(u: &mut Ui) {
|
||||
let modifiers = Modifiers::SHIFT;
|
||||
u.frame(vec![
|
||||
@@ -23,22 +18,11 @@ fn shift_i(u: &mut Ui) {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
/// The middle of the toolbar's Resources button, found in the AccessKit tree.
|
||||
fn resources_button(u: &mut Ui) -> Pos2 {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources");
|
||||
let b = n.bounds().expect("bounds");
|
||||
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
fn assets_shown(u: &Ui) -> bool {
|
||||
u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open
|
||||
}
|
||||
|
||||
/// The three states the sidebar can be in when Resources is asked for.
|
||||
/// The three states the sidebar can be in when Shift+I is pressed.
|
||||
fn starts() -> Vec<(&'static str, Ui)> {
|
||||
let shown = Ui::new(Session::new());
|
||||
let mut collapsed = Ui::new(Session::new());
|
||||
@@ -48,15 +32,6 @@ fn starts() -> Vec<(&'static str, Ui)> {
|
||||
vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resources_button_opens_the_assets_panel() {
|
||||
for (name, mut u) in starts() {
|
||||
let at = resources_button(&mut u);
|
||||
click(&mut u, at);
|
||||
assert!(assets_shown(&u), "{name}: the Assets panel is open");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_i_opens_the_assets_panel() {
|
||||
for (name, mut u) in starts() {
|
||||
@@ -65,3 +40,27 @@ fn shift_i_opens_the_assets_panel() {
|
||||
assert!(assets_shown(&u), "{name}: the Assets panel is open");
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar's buttons, as screen readers get them.
|
||||
fn toolbar_labels(u: &mut Ui) -> Vec<String> {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
out.platform_output
|
||||
.accesskit_update
|
||||
.expect("an AccessKit update")
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| n.role() == Role::Button)
|
||||
.filter_map(|(_, n)| n.label().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toolbar_has_no_resources_button() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let labels = toolbar_labels(&mut u);
|
||||
// Actions stays (the palette's button); Resources went (#1210).
|
||||
assert!(labels.iter().any(|l| l == "Actions"), "Actions is in the toolbar: {labels:?}");
|
||||
assert!(!labels.iter().any(|l| l == "Resources"), "no Resources button: {labels:?}");
|
||||
}
|
||||
|
||||
@@ -73,11 +73,6 @@ pub fn tip(label: &'static str, id: &str) -> &'static str {
|
||||
cached_tip(label, shortcut(id))
|
||||
}
|
||||
|
||||
/// A tooltip naming a key ("Resources ⇧I").
|
||||
pub fn key_tip(label: &'static str, key: &'static str) -> &'static str {
|
||||
cached_tip(label, Some(key))
|
||||
}
|
||||
|
||||
/// Panels ask for their tooltips every frame: each (label, key, platform) is built once.
|
||||
fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str {
|
||||
type Entry = (Platform, &'static str, &'static str, &'static str);
|
||||
|
||||
@@ -21,6 +21,19 @@ const CARET: f32 = 14.0;
|
||||
const FLYOUT_W: f32 = 16.0;
|
||||
const FLYOUT_GAP: f32 = 2.0;
|
||||
|
||||
// The pill's grid: every state of it (the tools, the vector and shape builder ones) is laid out on
|
||||
// one 32 pt cell, PAD in from its ends and GAP between its top-level items, the divider included.
|
||||
/// A toolbar button's size: the cell everything in the pill is placed on.
|
||||
const TOOL_CELL: f32 = 32.0;
|
||||
/// The pill's left and right padding.
|
||||
const PAD: f32 = 8.0;
|
||||
/// Between the pill's top-level items — the divider included.
|
||||
const GAP: f32 = 8.0;
|
||||
/// The divider's width.
|
||||
const DIVIDER: f32 = 1.0;
|
||||
/// The mode switcher's marks: smaller than the tools' (13–14 pt).
|
||||
const MODE_GLYPH: f32 = 14.0;
|
||||
|
||||
/// `GLYPH`'s box centred in `r`.
|
||||
fn glyph_rect(r: Rect) -> Rect {
|
||||
Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))
|
||||
@@ -129,17 +142,18 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 {
|
||||
main_width(if board { BOARD_GROUPS } else { GROUPS }, board)
|
||||
}
|
||||
|
||||
/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and
|
||||
/// Actions, and the mode switcher (boards: the Design panel's button instead).
|
||||
/// The main toolbar pill's width: the tool groups with their flyout carets, Actions, the divider
|
||||
/// and the mode switcher (boards: the templates and the Design panel's buttons instead).
|
||||
fn main_width(groups: &[Group], board: bool) -> f32 {
|
||||
let (cell, gap) = (TOOL_CELL, 4.0);
|
||||
let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W);
|
||||
let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 };
|
||||
groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end
|
||||
let tools: f32 = groups.iter().map(|g| TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 }).sum();
|
||||
// The end, and the gaps that aren't a group's own: after Actions and after the divider (and
|
||||
// between a board's two buttons, which the end counts as one item).
|
||||
let (end, gaps) = if board { (2.0 * TOOL_CELL, 3.0) } else { (4.0 * TOOL_CELL, 2.0) };
|
||||
PAD * 2.0 + tools + (groups.len() as f32 + gaps) * GAP + TOOL_CELL + DIVIDER + end
|
||||
}
|
||||
|
||||
/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the
|
||||
/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).
|
||||
/// The main toolbar pill: the tool groups with their flyouts, Actions, then the
|
||||
/// Draw · Design · Prototype · Inspect mode switcher (on a board, its two buttons).
|
||||
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {
|
||||
let groups = if board { BOARD_GROUPS } else { GROUPS };
|
||||
let width = main_width(groups, board);
|
||||
@@ -148,16 +162,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board:
|
||||
let scroll = pill_scroll(ui, rect, width);
|
||||
let outer_clip = ui.clip_rect();
|
||||
ui.set_clip_rect(rect.intersect(outer_clip));
|
||||
let mut x = rect.min.x + 8.0 - scroll;
|
||||
let mut x = rect.min.x + PAD - scroll;
|
||||
for g in groups {
|
||||
x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);
|
||||
}
|
||||
x = side_buttons(app, ui, x, cy, t);
|
||||
x = actions_button(app, ui, x, cy, t);
|
||||
divider(ui, t, x, cy);
|
||||
x += 8.0;
|
||||
x += DIVIDER + GAP;
|
||||
if board {
|
||||
// Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for.
|
||||
x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t);
|
||||
x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t) + GAP;
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let tip = crate::shortcuts::tip(tr("Design panel"), "view.design");
|
||||
if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() {
|
||||
@@ -185,9 +199,6 @@ fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 {
|
||||
scroll
|
||||
}
|
||||
|
||||
/// A toolbar button's size.
|
||||
const TOOL_CELL: f32 = 32.0;
|
||||
|
||||
/// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout
|
||||
/// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts.
|
||||
fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 {
|
||||
@@ -223,7 +234,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32
|
||||
options_flyout(app, ui, g, pos2(r.min.x, top - 8.0));
|
||||
}
|
||||
}
|
||||
x + 4.0
|
||||
x + GAP
|
||||
}
|
||||
|
||||
/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its
|
||||
@@ -245,36 +256,23 @@ fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &To
|
||||
}
|
||||
}
|
||||
|
||||
/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`.
|
||||
/// Where the next part starts.
|
||||
fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
|
||||
for (icon, tip, id) in [
|
||||
(Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),
|
||||
(Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip);
|
||||
if id == "actions" {
|
||||
app.ui.actions_rect = Some(r);
|
||||
}
|
||||
// The Actions button reads on while its panel is open; clicking it again closes it.
|
||||
let actions_open = id == "actions" && app.ui.palette.is_some();
|
||||
if actions_open {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon });
|
||||
if resp.clicked() {
|
||||
if id == "actions" {
|
||||
crate::menus::toggle_palette(&mut app.ui);
|
||||
} else {
|
||||
app.run("view.assets", json!({}));
|
||||
}
|
||||
}
|
||||
x += TOOL_CELL + 4.0;
|
||||
/// The Actions palette's button, from `x`. Where the next part starts.
|
||||
fn actions_button(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) -> f32 {
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let resp = ui.interact(r, ui.scope_id().with("actions"), Sense::click()).tip(crate::shortcuts::tip(tr("Actions"), "app.actions"));
|
||||
app.ui.actions_rect = Some(r);
|
||||
// The Actions button reads on while its panel is open; clicking it again closes it.
|
||||
let open = app.ui.palette.is_some();
|
||||
if open {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
x
|
||||
icons::paint(ui.painter(), glyph_rect(r), Icon::Actions, if open { t.accent_on } else { t.icon });
|
||||
if resp.clicked() {
|
||||
crate::menus::toggle_palette(&mut app.ui);
|
||||
}
|
||||
x + TOOL_CELL + GAP
|
||||
}
|
||||
|
||||
/// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code).
|
||||
@@ -286,19 +284,24 @@ fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) {
|
||||
crate::RightTab::Prototype => 2,
|
||||
crate::RightTab::Inspect => 3,
|
||||
};
|
||||
let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0));
|
||||
ui.painter().rect_filled(seg, CornerRadius::same(8), t.field);
|
||||
let seg = Rect::from_min_size(pos2(x, cy - TOOL_CELL / 2.0), vec2(4.0 * TOOL_CELL, TOOL_CELL));
|
||||
ui.painter().rect_filled(seg, CornerRadius::same(5), t.field);
|
||||
for (i, (icon, tip)) in modes.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));
|
||||
let r = Rect::from_min_size(pos2(seg.min.x + i as f32 * TOOL_CELL, seg.min.y), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip);
|
||||
a11y::toggle(&resp, tip, i == active);
|
||||
if i == active {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
// The current mode lifts with a plain inset, no outline.
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(3), t.panel);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(r.center(), vec2(MODE_GLYPH, MODE_GLYPH)),
|
||||
*icon,
|
||||
if i == active { t.accent_text } else { t.icon_secondary },
|
||||
);
|
||||
if resp.clicked() {
|
||||
pick_mode(app, i);
|
||||
}
|
||||
@@ -359,15 +362,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };
|
||||
let cell = 32.0;
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
|
||||
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
|
||||
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;
|
||||
let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w;
|
||||
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
let mut x = rect.min.x + PAD;
|
||||
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let active = app.session.tool.tool == tool;
|
||||
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));
|
||||
if active {
|
||||
@@ -376,10 +378,10 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
if resp.clicked() {
|
||||
app.run("tool.set", json!({"tool": tool.id()}));
|
||||
}
|
||||
x += cell + 4.0;
|
||||
x += TOOL_CELL + GAP;
|
||||
}
|
||||
divider(ui, &t, x, cy);
|
||||
let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));
|
||||
let badge = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 13.0), vec2(badge_w, 26.0));
|
||||
let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why);
|
||||
a11y::status(&resp, label, why);
|
||||
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
|
||||
@@ -391,15 +393,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let canvas = app.canvas.rect;
|
||||
let cell = 32.0;
|
||||
let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;
|
||||
let width = VTool::ALL.len() as f32 * (TOOL_CELL + GAP) + PAD * 2.0 + DIVIDER + GAP + DONE_W;
|
||||
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
let mut x = rect.min.x + PAD;
|
||||
// Holding Cmd with the Move tool bends: show it.
|
||||
let held = current == VTool::Move && ui.input(|i| i.modifiers.command);
|
||||
for tool in VTool::ALL {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let tip = if tool == VTool::Bend {
|
||||
trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))])
|
||||
} else {
|
||||
@@ -423,7 +424,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
if resp.clicked() {
|
||||
app.run("vector.edit.tool", json!({"tool": tool.id()}));
|
||||
}
|
||||
x += cell + 4.0;
|
||||
x += TOOL_CELL + GAP;
|
||||
}
|
||||
if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) {
|
||||
app.run("vector.edit.end", json!({}));
|
||||
@@ -433,7 +434,6 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
/// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done.
|
||||
fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let cell = 32.0;
|
||||
let remove = app.session.tool.builder_remove;
|
||||
let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());
|
||||
let alt = pretty_shortcut("Alt");
|
||||
@@ -445,12 +445,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
||||
trf("Click or drag to merge · {alt} removes", &[("alt", &alt)])
|
||||
};
|
||||
let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
|
||||
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
|
||||
let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + 2.0 * (DIVIDER + GAP) + galley.size().x + GAP + DONE_W;
|
||||
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
let mut x = rect.min.x + PAD;
|
||||
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let active = (mode == "remove") == remove;
|
||||
let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);
|
||||
if active {
|
||||
@@ -459,13 +459,13 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
||||
if resp.clicked() {
|
||||
app.run("shapeBuilder.mode", json!({"mode": mode}));
|
||||
}
|
||||
x += cell + 4.0;
|
||||
x += TOOL_CELL + GAP;
|
||||
}
|
||||
divider(ui, &t, x, cy);
|
||||
x += 12.0;
|
||||
x += DIVIDER + GAP;
|
||||
let w = galley.size().x;
|
||||
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);
|
||||
x += w + 12.0;
|
||||
x += w + GAP;
|
||||
if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
@@ -515,9 +515,10 @@ pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSal
|
||||
resp
|
||||
}
|
||||
|
||||
/// A vertical separator just after `x`.
|
||||
/// A vertical separator just after `x`: DIVIDER wide, as tall as the pill's content.
|
||||
fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {
|
||||
ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
|
||||
let half = TOOL_CELL / 2.0;
|
||||
ui.painter().line_segment([pos2(x + DIVIDER / 2.0, cy - half), pos2(x + DIVIDER / 2.0, cy + half)], Stroke::new(DIVIDER, t.border));
|
||||
}
|
||||
|
||||
const DONE_W: f32 = 56.0;
|
||||
@@ -525,7 +526,7 @@ const DONE_W: f32 = 56.0;
|
||||
/// A divider at `x` and the accent Done button after it; true when clicked.
|
||||
fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
|
||||
divider(ui, t, x, cy);
|
||||
let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
|
||||
let done = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 14.0), vec2(DONE_W, 28.0));
|
||||
let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip);
|
||||
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);
|
||||
@@ -642,12 +643,33 @@ mod tests {
|
||||
let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0));
|
||||
let pill = pill_rect(phone, full);
|
||||
assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");
|
||||
// Narrower than its items, so it scrolls under a drag or the wheel (#1209).
|
||||
assert!(full > pill.width(), "{full} in {}", pill.width());
|
||||
// A desktop canvas keeps the full width, and a degenerate one doesn't go negative.
|
||||
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
|
||||
assert_eq!(pill_rect(wide, full).width(), full);
|
||||
assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0);
|
||||
}
|
||||
|
||||
/// The width the pill asks for is the width its items take — the groups, Actions, the divider
|
||||
/// and the end (the mode switcher, or a board's two buttons), PAD in from either end and one
|
||||
/// GAP apart — so its last item ends exactly PAD from its right edge.
|
||||
#[test]
|
||||
fn the_pill_is_as_wide_as_its_items() {
|
||||
let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
|
||||
for (groups, board) in [(GROUPS, false), (BOARD_GROUPS, true)] {
|
||||
let pill = pill_rect(canvas, main_width(groups, board));
|
||||
let mut x = pill.min.x + PAD;
|
||||
for g in groups {
|
||||
x += TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 } + GAP;
|
||||
}
|
||||
x += TOOL_CELL + GAP; // Actions
|
||||
x += DIVIDER + GAP; // the divider
|
||||
let end = if board { 2.0 * TOOL_CELL + GAP } else { 4.0 * TOOL_CELL };
|
||||
assert!((x + end - (pill.max.x - PAD)).abs() < 0.01, "board {board}: {} vs {}", x + end, pill.max.x - PAD);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
|
||||
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -25,7 +25,8 @@ const GAP: f32 = 8.0;
|
||||
const TITLE_H: f32 = 26.0;
|
||||
|
||||
/// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker
|
||||
/// above the toolbar (whose top is `top`). Where the next button starts.
|
||||
/// above the toolbar (whose top is `top`). The button's right edge: the toolbar puts the next
|
||||
/// button a GAP after it.
|
||||
pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 {
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0));
|
||||
let open = app.ui.tool_flyout == Some(KEY);
|
||||
@@ -39,7 +40,7 @@ pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Toke
|
||||
if app.ui.tool_flyout == Some(KEY) {
|
||||
picker(app, ui, r, top - 8.0, t);
|
||||
}
|
||||
r.max.x + 4.0
|
||||
r.max.x
|
||||
}
|
||||
|
||||
fn area_id() -> egui::Id {
|
||||
|
||||
@@ -42,6 +42,15 @@ fn a_miter_limit_other_than_four_is_written() {
|
||||
assert_eq!(paths_of(&out.xml)[0].get("strokeMiterLimit"), Some("10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_miter_limit_is_written_as_a_finite_number() {
|
||||
let (mut d, r) = stroked(StrokeAlign::Center);
|
||||
d.get_mut(r).unwrap().stroke.miter_limit = 1e306;
|
||||
let out = export(&d, &[r], Compat::BestEffort);
|
||||
let v = paths_of(&out.xml)[0].get("strokeMiterLimit").unwrap().to_string();
|
||||
assert!(v.parse::<f32>().unwrap().is_finite(), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_uniform_scale_scales_the_stroke_width() {
|
||||
let (mut d, r) = stroked(StrokeAlign::Center);
|
||||
|
||||
@@ -19,6 +19,9 @@ pub(crate) fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
// Android reads these as 32-bit floats, so clamp to that range; this also keeps the scaling
|
||||
// below from overflowing to infinity.
|
||||
let v = v.clamp(-f64::from(f32::MAX), f64::from(f32::MAX));
|
||||
let r = (v * 1000.0).round() / 1000.0;
|
||||
let s = format!("{r}");
|
||||
if s == "-0" { "0".into() } else { s }
|
||||
|
||||
@@ -27,7 +27,7 @@ all end with the same geometry.
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
|
||||
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button |
|
||||
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) |
|
||||
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel |
|
||||
| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
|
||||
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
|
||||
|
||||
@@ -29,6 +29,8 @@ active connections**; additional connections are closed immediately. Socket read
|
||||
have a **60 s inactivity timeout**, so idle clients and clients that stop reading release their
|
||||
worker. This bounds blocked socket operations, not the total duration of a frame arriving slowly.
|
||||
Clients can reconnect after an idle timeout (the attached MCP backend already does).
|
||||
Replies have no size limit on the app's side. The attached MCP backend reads them up to **512 MiB
|
||||
before the newline** and drops the connection on a longer one (see [MCP server](mcp.md)).
|
||||
|
||||
Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting
|
||||
for another window's frame. A full socket queue replies with `"control request queue is full"`
|
||||
|
||||
@@ -17,7 +17,7 @@ and prototype interactions are still unchecked there.
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
|
||||
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
@@ -77,7 +77,8 @@ visible and opaque (a page also its background opacity): a reader takes what is
|
||||
draws nothing below a hidden page. The message
|
||||
lists one node change per layer, parents first, each with its GUID, its parent and a position
|
||||
string that orders it among its siblings; vector networks and outlines go in blobs, written once
|
||||
each.
|
||||
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
|
||||
source paint coordinates.
|
||||
|
||||
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
|
||||
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
|
||||
|
||||
+5
-2
@@ -79,9 +79,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
`.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and
|
||||
Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its
|
||||
warnings, size-checked (256 MB) before decoding.
|
||||
- **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path
|
||||
operations retain them with their source paint coordinates. `.fig` export still warns and drops
|
||||
region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`.
|
||||
- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of
|
||||
connectors without a saved snapshot, table cells beyond their own layers, filled areas of a
|
||||
vector with their own fills (#634, held for an owner decision on the document model), variables
|
||||
connectors without a saved snapshot, table cells beyond their own layers, live region
|
||||
style/variable links, variables
|
||||
bound to fields the editor can't bind and video fills aren't read; text lays out again with
|
||||
local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17
|
||||
changes were tested on files built in code, and paste from another design app on clipboard HTML
|
||||
|
||||
@@ -23,6 +23,10 @@ Attached calls are never automatically replayed after a transport failure: the a
|
||||
applied the request before its reply was lost. The error reports that the operation may have
|
||||
completed; inspect the document before retrying an edit. The next explicit call reconnects.
|
||||
If that connection cannot be established, the error says the request was not sent.
|
||||
A reply from the app is read up to **512 MiB before the newline** (the largest window capture is
|
||||
256 MiB as base64). The backend stops reading a longer reply at the first byte past the limit and
|
||||
treats it like a lost reply: the connection is closed, the operation may have completed, and the
|
||||
next call reconnects. For larger exports, give `export` a `path` or lower the scale.
|
||||
A connection the app closed while it sat idle is noticed before the next request is written and
|
||||
replaced then, so a pause between calls is not an error.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user