M4: text on a path and pattern fills (#118)
Text layers can follow a path (start offset, flip, alignment; edited in place; canvas, PNG, SVG, PDF; SVG textPath imports as real text on a path). A new pattern paint tiles a source layer in grid or hex rows with scale, spacing and alignment, live as the source changes (SVG pattern, PDF vector tiling pattern).
This commit is contained in:
parent
09600d6da2
commit
f62cb4b1f6
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **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
|
||||
|
|
@ -81,7 +81,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) |
|
||||
| Text | 85% | 64% | 8–14 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Text | 85% | 64% | 7–13 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
|
||||
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
|
|
@ -91,7 +91,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 45% | 30% | 10–18 | [gaps](docs/gaps.md) |
|
||||
| Figma Draw extras | 65% | 45% | 5–10 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Languages
|
||||
|
|
@ -171,6 +171,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! FigCraft colour and paint.
|
||||
//!
|
||||
//! [`Color`] is non-premultiplied sRGB in `0..=1` floats (what a design panel shows as hex + opacity).
|
||||
//! [`Paint`] is one entry of a node's fill or stroke list: solid, the four gradient kinds or an
|
||||
//! image, each with its own opacity, visibility and blend mode. [`ColorProfile`] says which RGB
|
||||
//! [`Paint`] is one entry of a node's fill or stroke list: solid, the four gradient kinds, an
|
||||
//! image or a pattern (a layer tiled), each with its own opacity, visibility and blend mode. [`ColorProfile`] says which RGB
|
||||
//! space (sRGB or Display P3) a document's values are in and converts between them.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
|
|
@ -427,6 +427,92 @@ fn one64() -> f64 {
|
|||
1.0
|
||||
}
|
||||
|
||||
/// How pattern tiles are laid out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PatternTile {
|
||||
/// Rows and columns.
|
||||
#[default]
|
||||
Grid,
|
||||
/// Every other row shifted by half a tile.
|
||||
HorizontalHex,
|
||||
/// Every other column shifted by half a tile.
|
||||
VerticalHex,
|
||||
}
|
||||
|
||||
/// Where the tile grid starts in the filled box: a tile in its top-left corner, centre or
|
||||
/// bottom-right corner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PatternAlign {
|
||||
#[default]
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/// A pattern paint: a layer of the document (`source`, a node id) drawn as a tile, repeated.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PatternPaint {
|
||||
pub source: u64,
|
||||
#[serde(default)]
|
||||
pub tile: PatternTile,
|
||||
/// Tile scale, 1 = the source's own size.
|
||||
#[serde(default = "one64")]
|
||||
pub scale: f64,
|
||||
/// Gap between tiles (horizontal, vertical), in document units.
|
||||
#[serde(default)]
|
||||
pub spacing: (f64, f64),
|
||||
#[serde(default)]
|
||||
pub align: PatternAlign,
|
||||
}
|
||||
|
||||
/// One repeating cell of a pattern in the filled box's space: `size` repeats from `origin`, and
|
||||
/// the source is drawn at each of `tiles` (its top-left), scaled by `scale`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PatternCell {
|
||||
pub size: (f64, f64),
|
||||
pub origin: (f64, f64),
|
||||
pub tiles: Vec<(f64, f64)>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl PatternPaint {
|
||||
/// The smallest and largest tile scale.
|
||||
pub const SCALE_RANGE: (f64, f64) = (0.01, 100.0);
|
||||
/// The largest gap between tiles.
|
||||
pub const MAX_SPACING: f64 = 10_000.0;
|
||||
|
||||
pub fn new(source: u64) -> Self {
|
||||
Self { source, tile: PatternTile::Grid, scale: 1.0, spacing: (0.0, 0.0), align: PatternAlign::Start }
|
||||
}
|
||||
|
||||
/// The cell for a `tw`×`th` source filling a `w`×`h` box; `None` when the source has no size.
|
||||
pub fn cell(&self, tw: f64, th: f64, w: f64, h: f64) -> Option<PatternCell> {
|
||||
let (lo, hi) = Self::SCALE_RANGE;
|
||||
let scale = if self.scale.is_finite() { self.scale.clamp(lo, hi) } else { 1.0 };
|
||||
let gap = |v: f64| if v.is_finite() { v.clamp(0.0, Self::MAX_SPACING) } else { 0.0 };
|
||||
let (tw, th) = (tw * scale, th * scale);
|
||||
if !(tw.is_finite() && th.is_finite() && tw > 1e-6 && th > 1e-6) {
|
||||
return None;
|
||||
}
|
||||
let (px, py) = (tw + gap(self.spacing.0), th + gap(self.spacing.1));
|
||||
let (size, tiles) = match self.tile {
|
||||
PatternTile::Grid => ((px, py), vec![(0.0, 0.0)]),
|
||||
// The shifted row (column) is drawn on both sides so it wraps across the cell's edge.
|
||||
PatternTile::HorizontalHex => ((px, py * 2.0), vec![(0.0, 0.0), (px / 2.0, py), (-px / 2.0, py)]),
|
||||
PatternTile::VerticalHex => ((px * 2.0, py), vec![(0.0, 0.0), (px, py / 2.0), (px, -py / 2.0)]),
|
||||
};
|
||||
let k = match self.align {
|
||||
PatternAlign::Start => 0.0,
|
||||
PatternAlign::Center => 0.5,
|
||||
PatternAlign::End => 1.0,
|
||||
};
|
||||
let fin = |v: f64| if v.is_finite() { v } else { 0.0 };
|
||||
Some(PatternCell { size, origin: (fin((w - tw) * k), fin((h - th) * k)), tiles, scale })
|
||||
}
|
||||
}
|
||||
|
||||
/// What a paint paints with.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
|
|
@ -434,6 +520,7 @@ pub enum PaintKind {
|
|||
Solid { color: Color },
|
||||
Gradient(Gradient),
|
||||
Image(ImagePaint),
|
||||
Pattern(PatternPaint),
|
||||
}
|
||||
|
||||
/// One fill or stroke paint.
|
||||
|
|
@ -478,6 +565,11 @@ impl Paint {
|
|||
}
|
||||
}
|
||||
|
||||
/// Layer `source` (a node id) tiled at its own size.
|
||||
pub fn pattern(source: u64) -> Self {
|
||||
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal }
|
||||
}
|
||||
|
||||
/// The solid colour (with the paint opacity folded into alpha), if solid.
|
||||
pub fn solid_color(&self) -> Option<Color> {
|
||||
match &self.kind {
|
||||
|
|
@ -497,6 +589,7 @@ impl Paint {
|
|||
GradientKind::Diamond => "Diamond".into(),
|
||||
},
|
||||
PaintKind::Image(_) => "Image".into(),
|
||||
PaintKind::Pattern(_) => "Pattern".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,7 +598,7 @@ impl Paint {
|
|||
match &mut self.kind {
|
||||
PaintKind::Solid { color } => f(color),
|
||||
PaintKind::Gradient(g) => g.stops.iter_mut().for_each(|s| f(&mut s.color)),
|
||||
PaintKind::Image(_) => {}
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -514,11 +607,13 @@ impl Paint {
|
|||
match &self.kind {
|
||||
PaintKind::Solid { color } => color.with_alpha(color.a * self.opacity),
|
||||
PaintKind::Gradient(g) => g.sample(0.0),
|
||||
PaintKind::Image(_) => Color::from_rgb8_const(0xB3, 0xB3, 0xB3),
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => Color::from_rgb8_const(0xB3, 0xB3, 0xB3),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pattern_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
//! Pattern cells: grid and half-shifted rows or columns, spacing, alignment, and sanitised values.
|
||||
|
||||
use crate::{PatternAlign, PatternPaint, PatternTile};
|
||||
|
||||
#[test]
|
||||
fn cells_tile_grids_and_shifted_rows() {
|
||||
let mut p = PatternPaint::new(7);
|
||||
p.spacing = (5.0, 10.0);
|
||||
let c = p.cell(20.0, 10.0, 100.0, 100.0).unwrap();
|
||||
assert_eq!((c.size, c.origin, c.tiles.len()), ((25.0, 20.0), (0.0, 0.0), 1));
|
||||
p.tile = PatternTile::HorizontalHex;
|
||||
let c = p.cell(20.0, 10.0, 100.0, 100.0).unwrap();
|
||||
assert_eq!(c.size, (25.0, 40.0));
|
||||
assert_eq!(c.tiles, vec![(0.0, 0.0), (12.5, 20.0), (-12.5, 20.0)]);
|
||||
p.tile = PatternTile::VerticalHex;
|
||||
assert_eq!(p.cell(20.0, 10.0, 100.0, 100.0).unwrap().tiles, vec![(0.0, 0.0), (25.0, 10.0), (25.0, -10.0)]);
|
||||
// Scale applies to the tile, not the gap; alignment puts a tile in the centre or the far corner.
|
||||
p.tile = PatternTile::Grid;
|
||||
p.scale = 2.0;
|
||||
p.align = PatternAlign::Center;
|
||||
let c = p.cell(20.0, 10.0, 100.0, 100.0).unwrap();
|
||||
assert_eq!((c.size, c.origin, c.scale), ((45.0, 30.0), (30.0, 40.0), 2.0));
|
||||
p.align = PatternAlign::End;
|
||||
assert_eq!(p.cell(20.0, 10.0, 100.0, 100.0).unwrap().origin, (60.0, 80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_sanitise_their_values() {
|
||||
let mut p = PatternPaint::new(1);
|
||||
assert!(p.cell(0.0, 10.0, 10.0, 10.0).is_none(), "a source without size");
|
||||
p.scale = f64::NAN;
|
||||
p.spacing = (f64::INFINITY, -5.0);
|
||||
let c = p.cell(10.0, 10.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!((c.scale, c.size), (1.0, (10.0, 10.0)));
|
||||
p.scale = 1e9;
|
||||
p.spacing = (1e9, 0.0);
|
||||
let c = p.cell(10.0, 10.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!((c.scale, c.size.0), (PatternPaint::SCALE_RANGE.1, 1000.0 + PatternPaint::MAX_SPACING));
|
||||
// Old files and hand-written paints: only the source is required.
|
||||
let p: crate::Paint = serde_json::from_str(r#"{"type":"pattern","source":3}"#).unwrap();
|
||||
assert_eq!(p.kind, crate::PaintKind::Pattern(PatternPaint::new(3)));
|
||||
}
|
||||
|
|
@ -701,8 +701,8 @@ impl Document {
|
|||
}
|
||||
|
||||
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
||||
/// remapped by `map`. Interactions and instance references pointing inside the subtree are
|
||||
/// remapped too.
|
||||
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
|
||||
/// subtree are remapped too.
|
||||
pub fn paste_nodes(
|
||||
&mut self,
|
||||
nodes: &[Node],
|
||||
|
|
@ -730,6 +730,14 @@ impl Document {
|
|||
{
|
||||
*component = *m;
|
||||
}
|
||||
// A pattern tiling a layer of the copy tiles the copy's layer.
|
||||
for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) {
|
||||
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.0.insert(*new_id);
|
||||
|
|
|
|||
|
|
@ -676,7 +676,8 @@ impl Node {
|
|||
0.0
|
||||
};
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
stroke + fx + self.spread_miter_reach()
|
||||
let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() } else { 0.0 };
|
||||
stroke + fx + self.spread_miter_reach() + glyphs
|
||||
}
|
||||
|
||||
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
|
||||
|
|
|
|||
|
|
@ -324,6 +324,50 @@ pub struct TextData {
|
|||
/// OpenType feature toggles (e.g. "liga": false).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
/// The path the text follows (text on a path); `None`: lines in the box.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<TextPath>,
|
||||
}
|
||||
|
||||
/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
|
||||
/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open
|
||||
/// path's ends are hidden, a closed path wraps them around.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TextPath {
|
||||
/// SVG path data in the layer's own space.
|
||||
pub d: String,
|
||||
/// Where the text starts along the path, 0..1 by length.
|
||||
pub start: f64,
|
||||
/// On the other side of the path, running the other way.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub flip: bool,
|
||||
}
|
||||
|
||||
impl TextPath {
|
||||
/// A text path following `path` (layer space).
|
||||
pub fn new(path: &figcraft_geom::BezPath) -> Self {
|
||||
Self { d: path.to_svg(), start: 0.0, flip: false }
|
||||
}
|
||||
|
||||
/// The path (empty when `d` doesn't parse).
|
||||
pub fn path(&self) -> figcraft_geom::BezPath {
|
||||
figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The start offset clamped into 0..1 (NaN: 0).
|
||||
pub fn start(&self) -> f64 {
|
||||
if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Scale the path by `sx`, `sy` (the layer was resized).
|
||||
pub fn scale(&mut self, sx: f64, sy: f64) {
|
||||
if sx.is_finite() && sy.is_finite() {
|
||||
let mut p = self.path();
|
||||
p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
|
||||
self.d = p.to_svg();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars).
|
||||
|
|
@ -341,6 +385,17 @@ impl TextData {
|
|||
Self { characters: characters.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// How far text on a path paints past its path's box (glyphs stand on the path, lines after
|
||||
/// the first run beside it): about one and a half of the largest size per line; 0 off a path.
|
||||
pub fn path_reach(&self) -> f64 {
|
||||
if self.path.is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
let size = self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0);
|
||||
let lines = self.characters.lines().count().clamp(1, 10_000) as f64;
|
||||
size * 1.5 * lines
|
||||
}
|
||||
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.characters.chars().count()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
|
||||
true,
|
||||
pattern_fill
|
||||
),
|
||||
spec!(
|
||||
"stroke.widthProfile",
|
||||
"Width profile",
|
||||
|
|
@ -1065,6 +1072,29 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
let doc = s.doc()?;
|
||||
if !doc.node(src).is_some_and(|n| !n.is_page()) {
|
||||
return Err(bad(CMD, "`source` must be a layer"));
|
||||
}
|
||||
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
|
||||
return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it"));
|
||||
}
|
||||
let mut pt = figcraft_color::PatternPaint::new(src.0);
|
||||
pt.tile = parse(CMD, p, "tile")?.unwrap_or_default();
|
||||
pt.align = parse(CMD, p, "align")?.unwrap_or_default();
|
||||
pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default();
|
||||
let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE;
|
||||
pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi);
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
q["paint"] =
|
||||
serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) })
|
||||
.map_err(|e| bad(CMD, e.to_string()))?;
|
||||
paint_set(s, &q, true)
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
|
|
|
|||
|
|
@ -97,8 +97,67 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and
|
||||
/// the path layer is removed.
|
||||
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "text.onPath";
|
||||
let doc = s.doc()?;
|
||||
let is_text = |i: &NodeId| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)));
|
||||
let sel = targets(s, p);
|
||||
let text =
|
||||
id(p, "text").or_else(|| sel.iter().copied().find(is_text)).ok_or_else(|| bad(CMD, "select a text layer and a path (or pass `text`)"))?;
|
||||
let path = id(p, "path")
|
||||
.or_else(|| sel.iter().copied().find(|i| *i != text && !is_text(i)))
|
||||
.ok_or_else(|| bad(CMD, "select a path or shape too (or pass `path`)"))?;
|
||||
let pn = doc.get(path)?;
|
||||
let geom = figcraft_doc::geometry::stroke_geometry(pn).filter(|g| !g.elements().is_empty()).ok_or_else(|| bad(CMD, "that layer has no path"))?;
|
||||
if !is_text(&text) || doc.is_ancestor(path, text) {
|
||||
return Err(bad(CMD, "`text` must be a text layer outside the path layer"));
|
||||
}
|
||||
let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path));
|
||||
let (transform, w, h) = (pn.transform, pn.width, pn.height);
|
||||
let mut tp = TextPath::new(&geom);
|
||||
tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
tp.flip = boolean(p, "flip").unwrap_or(false);
|
||||
let doc = s.doc_mut()?;
|
||||
doc.reparent(text, parent, index)?;
|
||||
doc.remove(path)?;
|
||||
let n = doc.get_mut(text)?;
|
||||
n.transform = transform;
|
||||
(n.width, n.height) = (w, h);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.path = Some(tp);
|
||||
}
|
||||
s.set_selection(vec![text]);
|
||||
Ok(json!({ "id": text }))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"text.onPath",
|
||||
"Text on path",
|
||||
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}",
|
||||
true,
|
||||
on_path
|
||||
),
|
||||
spec!("text.pathOptions", "Text path options", "{start? (0..1 along the path), flip?, ids?} for text on a path", true, |s, p| {
|
||||
let start = num(p, "start").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
|
||||
let flip = boolean(p, "flip");
|
||||
para(s, p, |t| {
|
||||
if let Some(tp) = &mut t.path {
|
||||
tp.start = start.unwrap_or(tp.start);
|
||||
tp.flip = flip.unwrap_or(tp.flip);
|
||||
}
|
||||
})
|
||||
}),
|
||||
spec!("text.removeFromPath", "Remove text from path", "{ids?} text on a path back to a line of auto-width text", true, |s, p| {
|
||||
para(s, p, |t| {
|
||||
if t.path.take().is_some() {
|
||||
t.auto_resize = TextAutoResize::WidthAndHeight;
|
||||
}
|
||||
})
|
||||
}),
|
||||
spec!("text.bold", "Bold", "{ids?}", true, toggle_bold),
|
||||
spec!("text.italic", "Italic", "{ids?}", true, toggle_italic),
|
||||
spec!("text.underline", "Underline", "{ids?}", true, |s, p| {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ fn first_fill(n: &Node) -> Option<Color> {
|
|||
n.fills.iter().filter(|p| p.visible).find_map(|p| match &p.kind {
|
||||
PaintKind::Solid { color } => Some(color.with_alpha(color.a * p.opacity)),
|
||||
PaintKind::Gradient(g) => g.sorted_stops().first().map(|s| s.color),
|
||||
PaintKind::Image(_) => None,
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -315,6 +315,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
|||
put("background", format!("linear-gradient({})", stops.join(", ")));
|
||||
}
|
||||
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ fn paint_row(p: &figcraft_color::Paint) -> Prop {
|
|||
}
|
||||
}
|
||||
PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
|
||||
PaintKind::Pattern(_) => prop("Pattern", format!("{}%", (p.opacity * 100.0).round())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Drawing extras: text on a path (commands, resize, undo, SVG round trip) and pattern fills
|
||||
//! (tiles render live from their source, copies keep their own source, exports).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_color::PaintKind;
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn text_path(s: &Session, t: NodeId) -> Option<figcraft_doc::TextPath> {
|
||||
match &s.doc().unwrap().node(t)?.kind {
|
||||
NodeKind::Text(td) => td.path.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its box.
|
||||
fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
|
||||
let i = ((y * pix.width() + x) * 4) as usize;
|
||||
let d = pix.data();
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_goes_onto_a_path_and_off_again() {
|
||||
let mut s = Session::new();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
|
||||
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap());
|
||||
// Without a path selected it says what's missing.
|
||||
assert!(s.execute("text.onPath", &json!({"ids": [t]})).is_err());
|
||||
let (vxf, vw) = s.doc().map(|d| d.node(v).map(|n| (n.transform, n.width))).unwrap().unwrap();
|
||||
let r = s.execute("text.onPath", &json!({"ids": [t, v], "start": 0.25})).unwrap();
|
||||
assert_eq!(id(r), t);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(v).is_none(), "the path layer became the text's path");
|
||||
let n = doc.node(t).unwrap();
|
||||
assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place");
|
||||
assert_eq!(s.selection(), vec![t]);
|
||||
let tp = text_path(&s, t).unwrap();
|
||||
assert_eq!((tp.start, tp.flip), (0.25, false));
|
||||
s.execute("text.pathOptions", &json!({"ids": [t], "flip": true, "start": 7})).unwrap();
|
||||
let tp = text_path(&s, t).unwrap();
|
||||
assert_eq!((tp.start, tp.flip), (1.0, true), "start clamps into 0..1");
|
||||
// Resizing scales the path with the box.
|
||||
let w = s.doc().unwrap().node(t).unwrap().width;
|
||||
s.execute("transform.resize", &json!({"ids": [t], "width": w * 2.0})).unwrap();
|
||||
let bb = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path());
|
||||
assert!((bb.width() - w * 2.0).abs() < 1.0, "{bb:?}");
|
||||
s.execute("text.removeFromPath", &json!({"ids": [t]})).unwrap();
|
||||
assert!(text_path(&s, t).is_none());
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_text_paths_import_as_text_on_a_path() {
|
||||
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
|
||||
if figcraft_text::find_face("Inter", "Regular").is_none() {
|
||||
eprintln!("no Inter; skipping");
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><path id="p" d="M10 80 Q100 0 190 80" fill="none"/><text font-size="12"><textPath href="#p" startOffset="50%" text-anchor="middle">Curved words</textPath></text></svg>"##;
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.current_page.unwrap();
|
||||
figcraft_svg::import_svg(doc, svg, page, 0.0, 0.0, "curve").unwrap();
|
||||
let texts: Vec<_> = s.doc().unwrap().nodes().filter_map(|n| n.kind.text().cloned()).collect();
|
||||
let t = texts.iter().find(|t| t.characters == "Curved words").expect("a text layer");
|
||||
let tp = t.path.as_ref().expect("on a path");
|
||||
assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start);
|
||||
assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fills_tile_their_source_live() {
|
||||
let mut s = Session::new();
|
||||
let tile = id(s.execute("shape.rectangle", &json!({"x": 500, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("fill.set", &json!({"ids": [tile], "color": "#FF0000"})).unwrap();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#FFFFFF"})).unwrap();
|
||||
s.execute("fill.add", &json!({"ids": [r]})).unwrap();
|
||||
assert!(s.execute("fill.pattern", &json!({"ids": [r], "source": r})).is_err(), "not itself");
|
||||
s.execute("fill.pattern", &json!({"ids": [r], "source": tile, "spacing": [10, 10]})).unwrap();
|
||||
assert!(matches!(&s.doc().unwrap().node(r).unwrap().fills[1].kind, PaintKind::Pattern(p) if p.source == tile.0 && p.spacing == (10.0, 10.0)));
|
||||
assert_eq!(pixel(&s, r, 5, 5), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&s, r, 15, 5), [255, 255, 255, 255], "the gap shows the fill below");
|
||||
assert_eq!(pixel(&s, r, 45, 45), [255, 0, 0, 255]);
|
||||
// Live: recolouring the source recolours the tiles.
|
||||
s.execute("fill.set", &json!({"ids": [tile], "color": "#0000FF"})).unwrap();
|
||||
assert_eq!(pixel(&s, r, 5, 5), [0, 0, 255, 255]);
|
||||
// Half-shifted rows.
|
||||
s.execute("fill.pattern", &json!({"ids": [r], "source": tile, "spacing": [10, 10], "tile": "horizontalHex"})).unwrap();
|
||||
assert_eq!(pixel(&s, r, 15, 25), [0, 0, 255, 255]);
|
||||
assert_eq!(pixel(&s, r, 5, 25), [255, 255, 255, 255]);
|
||||
// A copy of the source together with the layer tiles the copied source.
|
||||
s.execute("object.group", &json!({"ids": [tile, r]})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let sources: Vec<u64> = doc
|
||||
.nodes()
|
||||
.flat_map(|n| n.fills.iter())
|
||||
.filter_map(|p| match &p.kind {
|
||||
PaintKind::Pattern(pt) => Some(pt.source),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sources.len(), 2);
|
||||
assert_ne!(sources[0], sources[1], "each copy tiles its own source");
|
||||
// Exports: SVG as a <pattern> of vectors, PDF without failing.
|
||||
let svg = figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("<pattern") && svg.contains("fill=\"url(#pattern"), "{svg}");
|
||||
assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_of_itself_draws_nothing_instead_of_recursing() {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
|
||||
// Hand-written (the command refuses this): the layer tiles itself.
|
||||
s.execute("fill.set", &json!({"ids": [r], "paint": {"type": "pattern", "source": r.0, "scale": 0.25}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(figcraft_render::render_node(doc, r, 1.0).is_some());
|
||||
assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some());
|
||||
assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok());
|
||||
}
|
||||
|
|
@ -163,6 +163,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
|||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1208,6 +1208,8 @@ mod devfocus_tests;
|
|||
#[cfg(test)]
|
||||
mod devinspect_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub use kurbo::{Affine, BezPath, Point, Rect, Shape, Size, Vec2};
|
|||
use kurbo::ParamCurve;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
pub mod measure;
|
||||
mod transform;
|
||||
pub use transform::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
//! Paths measured by length: subpaths flattened into polylines with the length along them, for
|
||||
//! anything placed along a path (variable width strokes, text on a path).
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Vec2};
|
||||
|
||||
/// Flattening tolerance (local units).
|
||||
pub const FLATTEN_TOL: f64 = 0.05;
|
||||
/// The most flattened points a path is measured with; longer paths are refused.
|
||||
pub const MAX_SAMPLES: usize = 40_000;
|
||||
|
||||
/// Every coordinate is finite and of a sane magnitude.
|
||||
pub fn finite_path(path: &BezPath) -> bool {
|
||||
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
||||
path.elements().iter().all(|el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p),
|
||||
PathEl::QuadTo(a, b) => ok(a) && ok(b),
|
||||
PathEl::CurveTo(a, b, c) => ok(a) && ok(b) && ok(c),
|
||||
PathEl::ClosePath => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// One subpath flattened, with the length along it at each point. A closed subpath ends on its
|
||||
/// start point.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Polyline {
|
||||
pub points: Vec<Point>,
|
||||
/// Cumulative length at each point (starts at 0).
|
||||
pub lengths: Vec<f64>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
pub fn length(&self) -> f64 {
|
||||
self.lengths.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn push(&mut self, p: Point) {
|
||||
let Some(last) = self.points.last().copied() else {
|
||||
self.points.push(p);
|
||||
self.lengths.push(0.0);
|
||||
return;
|
||||
};
|
||||
let d = (p - last).hypot();
|
||||
if d > 1e-9 {
|
||||
self.points.push(p);
|
||||
self.lengths.push(self.length() + d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at `t` (0..1 by length) and the unit normal there (the direction turned 90°).
|
||||
pub fn at(&self, t: f64) -> Option<(Point, Vec2)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 || !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let s = t.clamp(0.0, 1.0) * len;
|
||||
let i = self.lengths.partition_point(|l| *l <= s).clamp(1, self.points.len().saturating_sub(1).max(1));
|
||||
let (a, b) = (*self.points.get(i - 1)?, *self.points.get(i)?);
|
||||
let (la, lb) = (*self.lengths.get(i - 1)?, *self.lengths.get(i)?);
|
||||
let u = if lb - la > 1e-12 { ((s - la) / (lb - la)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = b - a;
|
||||
let n = d.turn_90() / d.hypot().max(1e-12);
|
||||
Some((a.lerp(b, u), n))
|
||||
}
|
||||
|
||||
/// The nearest point to `p`: (t by length, distance).
|
||||
pub fn nearest(&self, p: Point) -> Option<(f64, f64)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (i, w) in self.points.windows(2).enumerate() {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let l2 = d.hypot2();
|
||||
let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = (a.lerp(b, u) - p).hypot();
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
let s = self.lengths.get(i).copied().unwrap_or(0.0) + u * l2.sqrt();
|
||||
best = Some((s / len, dist));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subpath of `path` flattened. `None` when the path has invalid coordinates or is too
|
||||
/// long to work with.
|
||||
pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
||||
if !finite_path(path) {
|
||||
return None;
|
||||
}
|
||||
fn finish(cur: &mut Polyline, out: &mut Vec<Polyline>) {
|
||||
let done = std::mem::take(cur);
|
||||
if done.points.len() >= 2 {
|
||||
out.push(done);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Polyline> = Vec::new();
|
||||
let mut cur = Polyline::default();
|
||||
let mut start = Point::ZERO;
|
||||
let mut count = 0usize;
|
||||
kurbo::flatten(path.iter(), FLATTEN_TOL, |el| {
|
||||
count += 1;
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
finish(&mut cur, &mut out);
|
||||
start = p;
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
if cur.points.is_empty() {
|
||||
cur.push(start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
cur.push(start);
|
||||
cur.closed = cur.points.len() >= 3;
|
||||
finish(&mut cur, &mut out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
finish(&mut cur, &mut out);
|
||||
(count <= MAX_SAMPLES).then_some(out)
|
||||
}
|
||||
|
|
@ -92,10 +92,12 @@ pub fn set_size(doc: &mut Document, id: NodeId, w: f64, h: f64) {
|
|||
if (ow - w).abs() < 1e-9 && (oh - h).abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let sx = if ow > 1e-9 { w / ow } else { 1.0 };
|
||||
let sy = if oh > 1e-9 { h / oh } else { 1.0 };
|
||||
network.scale(sx, sy);
|
||||
let sx = if ow > 1e-9 { w / ow } else { 1.0 };
|
||||
let sy = if oh > 1e-9 { h / oh } else { 1.0 };
|
||||
match &mut n.kind {
|
||||
NodeKind::Vector { network } => network.scale(sx, sy),
|
||||
NodeKind::Text(figcraft_doc::TextData { path: Some(tp), .. }) => tp.scale(sx, sy),
|
||||
_ => {}
|
||||
}
|
||||
n.width = w;
|
||||
n.height = h;
|
||||
|
|
|
|||
|
|
@ -53,15 +53,7 @@ pub fn closed(path: &BezPath) -> BezPath {
|
|||
}
|
||||
|
||||
/// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input).
|
||||
pub fn finite(path: &BezPath) -> bool {
|
||||
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
||||
path.elements().iter().all(|el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p),
|
||||
PathEl::QuadTo(a, b) => ok(a) && ok(b),
|
||||
PathEl::CurveTo(a, b, c) => ok(a) && ok(b) && ok(c),
|
||||
PathEl::ClosePath => true,
|
||||
})
|
||||
}
|
||||
pub use figcraft_geom::measure::finite_path as finite;
|
||||
|
||||
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
|
||||
if a.elements().is_empty() && b.elements().is_empty() {
|
||||
|
|
|
|||
|
|
@ -8,126 +8,18 @@
|
|||
//! fill it with the non-zero rule.
|
||||
|
||||
use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile};
|
||||
use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
|
||||
use figcraft_geom::kurbo::{self, BezPath, Point, Vec2};
|
||||
use figcraft_geom::measure::FLATTEN_TOL;
|
||||
/// The most points the outline works with; longer paths fall back to a uniform stroke.
|
||||
pub use figcraft_geom::measure::MAX_SAMPLES as MAX_WIDTH_SAMPLES;
|
||||
pub use figcraft_geom::measure::{Polyline, polylines};
|
||||
|
||||
/// Flattening tolerance (local units).
|
||||
const FLATTEN_TOL: f64 = 0.05;
|
||||
/// Every subpath gets at least this many width samples, so the width varies smoothly along long
|
||||
/// straight runs.
|
||||
const SAMPLES_PER_SUBPATH: f64 = 128.0;
|
||||
/// The most points the outline works with; longer paths fall back to a uniform stroke.
|
||||
pub const MAX_WIDTH_SAMPLES: usize = 40_000;
|
||||
/// The most dashes a dashed variable width stroke makes.
|
||||
const MAX_DASHES: usize = 20_000;
|
||||
|
||||
/// One subpath flattened, with the length along it at each point. A closed subpath ends on its
|
||||
/// start point.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Polyline {
|
||||
pub points: Vec<Point>,
|
||||
/// Cumulative length at each point (starts at 0).
|
||||
pub lengths: Vec<f64>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
pub fn length(&self) -> f64 {
|
||||
self.lengths.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn push(&mut self, p: Point) {
|
||||
let Some(last) = self.points.last().copied() else {
|
||||
self.points.push(p);
|
||||
self.lengths.push(0.0);
|
||||
return;
|
||||
};
|
||||
let d = (p - last).hypot();
|
||||
if d > 1e-9 {
|
||||
self.points.push(p);
|
||||
self.lengths.push(self.length() + d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at `t` (0..1 by length) and the unit normal there (the direction turned 90°).
|
||||
pub fn at(&self, t: f64) -> Option<(Point, Vec2)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 || !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let s = t.clamp(0.0, 1.0) * len;
|
||||
let i = self.lengths.partition_point(|l| *l <= s).clamp(1, self.points.len().saturating_sub(1).max(1));
|
||||
let (a, b) = (*self.points.get(i - 1)?, *self.points.get(i)?);
|
||||
let (la, lb) = (*self.lengths.get(i - 1)?, *self.lengths.get(i)?);
|
||||
let u = if lb - la > 1e-12 { ((s - la) / (lb - la)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = b - a;
|
||||
let n = d.turn_90() / d.hypot().max(1e-12);
|
||||
Some((a.lerp(b, u), n))
|
||||
}
|
||||
|
||||
/// The nearest point to `p`: (t by length, distance).
|
||||
pub fn nearest(&self, p: Point) -> Option<(f64, f64)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (i, w) in self.points.windows(2).enumerate() {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let l2 = d.hypot2();
|
||||
let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = (a.lerp(b, u) - p).hypot();
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
let s = self.lengths.get(i).copied().unwrap_or(0.0) + u * l2.sqrt();
|
||||
best = Some((s / len, dist));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subpath of `path` flattened. `None` when the path has invalid coordinates or is too
|
||||
/// long to work with.
|
||||
pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
||||
if !crate::finite(path) {
|
||||
return None;
|
||||
}
|
||||
fn finish(cur: &mut Polyline, out: &mut Vec<Polyline>) {
|
||||
let done = std::mem::take(cur);
|
||||
if done.points.len() >= 2 {
|
||||
out.push(done);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Polyline> = Vec::new();
|
||||
let mut cur = Polyline::default();
|
||||
let mut start = Point::ZERO;
|
||||
let mut count = 0usize;
|
||||
kurbo::flatten(path.iter(), FLATTEN_TOL, |el| {
|
||||
count += 1;
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
finish(&mut cur, &mut out);
|
||||
start = p;
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
if cur.points.is_empty() {
|
||||
cur.push(start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
cur.push(start);
|
||||
cur.closed = cur.points.len() >= 3;
|
||||
finish(&mut cur, &mut out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
finish(&mut cur, &mut out);
|
||||
(count <= MAX_WIDTH_SAMPLES).then_some(out)
|
||||
}
|
||||
|
||||
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
|
||||
pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> {
|
||||
lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use figcraft_color::{BlendMode, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_color::{BlendMode, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternPaint};
|
||||
use figcraft_doc::{Document, EffectKind, MaskType as DocMaskType, Node, NodeId, NodeKind, StrokeAlign};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
|
||||
|
|
@ -417,6 +417,11 @@ impl<'a> Exporter<'a> {
|
|||
s.push_transform(&to_xf(xf));
|
||||
match &p.kind {
|
||||
PaintKind::Image(img) => self.image_fill(s, &kp, r, img, p.opacity.clamp(0.0, 1.0), w, h),
|
||||
PaintKind::Pattern(pt) => {
|
||||
if !self.pattern_fill(s, &kp, r, pt, p.opacity.clamp(0.0, 1.0), w, h) {
|
||||
self.raster_paint(s, path, &kp, r, p, w, h, xf);
|
||||
}
|
||||
}
|
||||
PaintKind::Gradient(g) if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => {
|
||||
self.raster_paint(s, path, &kp, r, p, w, h, xf)
|
||||
}
|
||||
|
|
@ -498,8 +503,46 @@ impl<'a> Exporter<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Angular and diamond gradients: an image of the paint over the path's bounds, clipped to
|
||||
/// it. The surface is in the node's local space; `xf` (local → page) sets the resolution.
|
||||
/// A pattern fill as a PDF tiling pattern of the source's vectors; false (draw it as an image
|
||||
/// instead) when the source has effects, which the PDF draws as page-placed images.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pattern_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, pt: &PatternPaint, op: f32, w: f64, h: f64) -> bool {
|
||||
let doc = self.doc;
|
||||
let Some(src) = doc.node(NodeId(pt.source)).filter(|n| !n.is_page()) else { return true };
|
||||
if doc.subtree(src.id).iter().filter_map(|i| doc.node(*i)).any(|n| n.effects.iter().any(|e| e.visible)) {
|
||||
return false;
|
||||
}
|
||||
let Some(cell) = pt.cell(src.width, src.height, w, h) else { return true };
|
||||
let unrotated = src.transform.inverse();
|
||||
let stream = {
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ps = sb.surface();
|
||||
// Links and backdrops belong to the page, not to a tile.
|
||||
self.masking += 1;
|
||||
let _ = figcraft_render::pattern::nested(|| {
|
||||
for (tx, ty) in &cell.tiles {
|
||||
self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false);
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
self.masking -= 1;
|
||||
ps.finish();
|
||||
sb.finish()
|
||||
};
|
||||
if op < 1.0 {
|
||||
s.push_opacity(norm(op));
|
||||
}
|
||||
let pattern = Pattern { stream, transform: to_xf(Affine::translate(cell.origin)), width: cell.size.0 as f32, height: cell.size.1 as f32 };
|
||||
s.set_fill(Some(Fill { paint: pattern.into(), opacity: NormalizedF32::ONE, rule: r }));
|
||||
s.draw_path(kp);
|
||||
if op < 1.0 {
|
||||
s.pop();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Angular and diamond gradients, and patterns of sources with effects: an image of the paint
|
||||
/// over the path's bounds, clipped to it. The surface is in the node's local space; `xf` (local → page) sets the resolution.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
|
||||
let bb = path.bounding_box();
|
||||
|
|
@ -513,7 +556,7 @@ impl<'a> Exporter<'a> {
|
|||
k *= MAX_RASTER_SIDE / side;
|
||||
}
|
||||
let (pw, ph) = ((bb.width() * k).ceil().max(1.0) as u32, (bb.height() * k).ceil().max(1.0) as u32);
|
||||
let Some(prep) = figcraft_render::paint::prepare(p, w, h, self.doc) else { return };
|
||||
let Some(prep) = figcraft_render::paint::prepare(p, w, h, k, self.doc) else { return };
|
||||
let Some(mut pix) = tiny_skia::Pixmap::new(pw, ph) else { return };
|
||||
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: false, ..Default::default() };
|
||||
let Some(rect) = tiny_skia::Rect::from_xywh(bb.x0 as f32, bb.y0 as f32, bb.width() as f32, bb.height() as f32) else { return };
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
|
|||
}
|
||||
let embeddable = matches!(&p.kind, PaintKind::Solid { .. })
|
||||
|| matches!(&p.kind, PaintKind::Gradient(g) if matches!(g.kind, GradientKind::Linear | GradientKind::Radial));
|
||||
if embeddable && !real_text_written && !ex.opts.outline_text {
|
||||
// shortcut: text on a path is written as outlines (not selectable); turn each glyph
|
||||
// with its own text matrix when selectable path text is asked for.
|
||||
if embeddable && !real_text_written && !ex.opts.outline_text && layout.on_path.is_none() {
|
||||
// The first fill writes the text itself; the rest paint its outlines on top.
|
||||
real_text_written = true;
|
||||
let mut rest = glyph_runs(ex, s, &layout, k, &chars, p, w, h, gxf);
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
|
|||
}
|
||||
|
||||
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
|
||||
let scale = xf.determinant().abs().sqrt();
|
||||
for p in paints {
|
||||
let Some(prep) = prepare_at(p, w, h, ctx.doc, ctx.time) else { continue };
|
||||
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
|
||||
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
|
||||
pix.fill_path(path, &paint, rule, ts_transform(xf), mask);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod grain;
|
|||
pub mod images;
|
||||
pub mod lod;
|
||||
pub mod paint;
|
||||
pub mod pattern;
|
||||
pub mod retained;
|
||||
pub mod shadow;
|
||||
|
||||
|
|
|
|||
|
|
@ -58,13 +58,13 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Pai
|
|||
n.fills.iter().chain(t.runs.iter().filter_map(|r| r.style.fills.as_ref()).flatten())
|
||||
}
|
||||
|
||||
/// Whether the subtree of `n` can be splatted: no image paints (their colours aren't known
|
||||
/// Whether the subtree of `n` can be splatted: no image or pattern paints (their colours aren't known
|
||||
/// without sampling them) and no masks (which change what their siblings show).
|
||||
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_));
|
||||
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),
|
||||
_ => n.fills.iter().any(image),
|
||||
|
|
@ -89,7 +89,7 @@ fn paint_color(p: &Paint) -> Option<[f32; 4]> {
|
|||
let n = stops.len().max(1) as f32;
|
||||
stops.iter().fold([0.0; 4], |acc, s| [acc[0] + s.color.r / n, acc[1] + s.color.g / n, acc[2] + s.color.b / n, acc[3] + s.color.a / n])
|
||||
}
|
||||
PaintKind::Image(_) => return None,
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => return None,
|
||||
};
|
||||
let c = c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
|
||||
(c[3] * op > 0.0).then_some([c[0], c[1], c[2], c[3] * op])
|
||||
|
|
|
|||
|
|
@ -81,14 +81,15 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
|
|||
Some(Arc::new(p))
|
||||
}
|
||||
|
||||
/// Build the shader for `paint` on a `w`×`h` local box. Returns `None` when the paint draws
|
||||
/// nothing (invisible, fully transparent, missing image).
|
||||
pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared> {
|
||||
prepare_at(paint, w, h, doc, 0.0)
|
||||
/// Build the shader for `paint` on a `w`×`h` local box shown at `scale` device pixels per unit
|
||||
/// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing
|
||||
/// (invisible, fully transparent, missing image or pattern source).
|
||||
pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option<Prepared> {
|
||||
prepare_at(paint, w, h, scale, doc, 0.0)
|
||||
}
|
||||
|
||||
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame).
|
||||
pub fn prepare_at(paint: &Paint, w: f64, h: f64, doc: &Document, time: f64) -> Option<Prepared> {
|
||||
pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64) -> Option<Prepared> {
|
||||
if !paint.visible || paint.opacity <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -144,6 +145,7 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, doc: &Document, time: f64) -> O
|
|||
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
|
||||
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
|
||||
}
|
||||
PaintKind::Pattern(pt) => crate::pattern::prepare(doc, pt, w, h, scale, time, op),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
//! Pattern fills: the source layer drawn into one repeating cell at the resolution it shows at,
|
||||
//! repeated by a tiling shader.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::{PatternCell, PatternPaint};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform};
|
||||
|
||||
use crate::draw::{Ctx, draw_node};
|
||||
use crate::paint::Prepared;
|
||||
|
||||
/// The largest side of a cell's pixmap; a cell showing larger is drawn coarser.
|
||||
const MAX_CELL_PX: f64 = 2048.0;
|
||||
/// How deep patterns nest (a source filled with a pattern); deeper ones, and a source filled
|
||||
/// with its own pattern, draw nothing.
|
||||
pub const MAX_DEPTH: u32 = 2;
|
||||
|
||||
thread_local! {
|
||||
static DEPTH: Cell<u32> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Run `f` one pattern level deeper; `None` past [`MAX_DEPTH`].
|
||||
pub fn nested<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
let d = DEPTH.with(Cell::get);
|
||||
if d >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
DEPTH.with(|c| c.set(d + 1));
|
||||
let out = f();
|
||||
DEPTH.with(|c| c.set(d));
|
||||
out
|
||||
}
|
||||
|
||||
/// Pattern `pt` on a `w`×`h` box drawn into one cell at about `scale` device pixels per unit and
|
||||
/// `time` seconds into animated GIFs:
|
||||
/// the pixmap, the cell and the pixmap's pixels per unit on each axis (whole pixels per cell, so
|
||||
/// the repeat lines up exactly).
|
||||
pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64, time: f64) -> Option<(Pixmap, PatternCell, (f64, f64))> {
|
||||
let src = doc.node(NodeId(pt.source)).filter(|n| !n.is_page())?;
|
||||
let cell = pt.cell(src.width, src.height, w, h)?;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let k = scale.min(MAX_CELL_PX / cell.size.0.max(cell.size.1));
|
||||
let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX));
|
||||
let (kx, ky) = (pw / cell.size.0, ph / cell.size.1);
|
||||
let mut pix = Pixmap::new(pw as u32, ph as u32)?;
|
||||
let ctx = Ctx { doc, scale: kx.max(ky) * cell.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None, time };
|
||||
let unrotated = src.transform.inverse();
|
||||
nested(|| {
|
||||
for (tx, ty) in &cell.tiles {
|
||||
let xf = Affine::scale_non_uniform(kx, ky) * Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated;
|
||||
draw_node(&ctx, &mut pix, src.id, xf, 0);
|
||||
}
|
||||
Some(())
|
||||
})?;
|
||||
Some((pix, cell, (kx, ky)))
|
||||
}
|
||||
|
||||
/// The shader for pattern `pt` on a `w`×`h` box shown at `scale` device pixels per unit.
|
||||
pub fn prepare(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64, time: f64, opacity: f32) -> Option<Prepared> {
|
||||
let (pix, cell, (kx, ky)) = cell_pixmap(doc, pt, w, h, scale, time)?;
|
||||
let transform = Transform::from_row((1.0 / kx) as f32, 0.0, 0.0, (1.0 / ky) as f32, cell.origin.0 as f32, cell.origin.1 as f32);
|
||||
Some(Prepared::Pattern { pixmap: Arc::new(pix), mode: SpreadMode::Repeat, quality: FilterQuality::Bilinear, opacity, transform })
|
||||
}
|
||||
|
|
@ -61,13 +61,28 @@ struct Frame {
|
|||
struct Sensitive {
|
||||
areas: Vec<Rect>,
|
||||
backdrop_blur: f64,
|
||||
/// Pattern fill sources: an edit inside one shows wherever the pattern does.
|
||||
pattern_sources: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// Grain effects (noise, texture) work on their layer's pixels, which end where a render cuts the
|
||||
/// layer (texture scatters pixels from across the cut): grainy layers are redrawn whole.
|
||||
fn sensitive(doc: &Document) -> Sensitive {
|
||||
let noisy = doc.nodes().filter(|n| n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::Noise(_) | EffectKind::Texture(_))));
|
||||
Sensitive { areas: noisy.filter_map(|n| draw::paint_bounds(doc, n.id)).collect(), backdrop_blur: crate::max_backdrop_blur(doc) }
|
||||
let pattern_sources = doc
|
||||
.nodes()
|
||||
.flat_map(|n| n.fills.iter().chain(&n.strokes))
|
||||
.filter_map(|p| match &p.kind {
|
||||
figcraft_color::PaintKind::Pattern(pt) => Some(NodeId(pt.source)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Sensitive { areas: noisy.filter_map(|n| draw::paint_bounds(doc, n.id)).collect(), backdrop_blur: crate::max_backdrop_blur(doc), pattern_sources }
|
||||
}
|
||||
|
||||
/// Whether `id` is a pattern source or inside one in `doc`.
|
||||
fn in_pattern_source(doc: &Document, s: &Sensitive, id: NodeId) -> bool {
|
||||
s.pattern_sources.iter().any(|src| *src == id || doc.is_ancestor(*src, id))
|
||||
}
|
||||
|
||||
/// A view snapped outwards to whole device pixels: the origin on its zoom's pixel grid and one
|
||||
|
|
@ -270,6 +285,10 @@ fn patch(last: Frame, doc: &Arc<Document>, page: NodeId, view: &View, px: (i64,
|
|||
if !Arc::ptr_eq(&last.doc, doc) {
|
||||
let mut areas = Vec::new();
|
||||
for id in doc.changed_ids(&last.doc, MAX_CHANGED)? {
|
||||
// shortcut: an edited pattern source redraws the whole view; patch the patterned layers' areas when that shows up in profiles.
|
||||
if in_pattern_source(doc, s, id) || in_pattern_source(&last.doc, &last.sensitive, id) {
|
||||
return None;
|
||||
}
|
||||
changed_areas(&last.doc, page, id, &mut areas)?;
|
||||
changed_areas(doc, page, id, &mut areas)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//!
|
||||
//! usvg lays text out before handing it over and drops what it can't lay out, so one face from
|
||||
//! FigCraft's font database stands in for every family while parsing; FigCraft then lays the text
|
||||
//! out with the real fonts. Text on a path, with per-glyph rotation or vertical comes in as outlines.
|
||||
//! out with the real fonts. Text on one path comes in as text on a path; text on several paths,
|
||||
//! with per-glyph rotation or vertical comes in as outlines.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -165,7 +166,11 @@ pub(crate) fn text(cx: &mut Ctx, t: &usvg::Text, parent: NodeId, base: Affine, d
|
|||
return;
|
||||
}
|
||||
let along_path = t.chunks().iter().any(|c| matches!(c.text_flow(), usvg::TextFlow::Path(_)));
|
||||
if along_path || t.rotate().iter().any(|r| *r != 0.0) || t.writing_mode() == usvg::WritingMode::TopToBottom {
|
||||
let plain = !t.rotate().iter().any(|r| *r != 0.0) && t.writing_mode() != usvg::WritingMode::TopToBottom;
|
||||
if along_path && plain && on_path(cx, t, world, parent) {
|
||||
return;
|
||||
}
|
||||
if along_path || !plain {
|
||||
cx.note("text on a path, vertical or with rotated glyphs came in as outlines");
|
||||
if let Some(id) = cx.group(parent, &layer_name(t.id(), "Text"), true) {
|
||||
children(cx, t.flattened(), id, base, depth + 1);
|
||||
|
|
@ -246,24 +251,70 @@ fn text_data(lines: &[Line], s: f64) -> TextData {
|
|||
td
|
||||
}
|
||||
|
||||
/// The text layer for `block` (lines sharing an anchor, x and pitch).
|
||||
fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world: Affine, parent: NodeId) {
|
||||
let Some(first) = block.first() else { return };
|
||||
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
|
||||
if spans.iter().all(|sp| !sp.is_visible()) {
|
||||
return;
|
||||
}
|
||||
let mut td = text_data(block, s);
|
||||
td.align_h = match first.anchor {
|
||||
/// Text on one path (a single `<textPath>`) as a text layer on that path; false when the text
|
||||
/// isn't one chunk on one path.
|
||||
fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool {
|
||||
let [chunk] = t.chunks() else { return false };
|
||||
let usvg::TextFlow::Path(tp) = chunk.text_flow() else { return false };
|
||||
let path = super::to_bez(tp.path());
|
||||
let bb = figcraft_geom::Shape::bounding_box(&path);
|
||||
let Some(len) = figcraft_geom::measure::polylines(&path).and_then(|l| l.first().map(|l| l.length())).filter(|l| *l > 1e-9) else { return false };
|
||||
let fonts = cx.fonts.get_or_insert_with(Fonts::load);
|
||||
let text = chunk.text();
|
||||
let count = text.chars().count();
|
||||
let ci = |b: usize| text.get(..b).map_or(count, |p| p.chars().count());
|
||||
let spans: Vec<_> = chunk.spans().iter().map(|sp| (ci(sp.start()), ci(sp.end()), sp, span_style(sp, 1.0, fonts))).collect();
|
||||
let line = Line { x: 0.0, y: 0.0, anchor: chunk.anchor(), text: text.to_string(), spans };
|
||||
let mut td = text_data(std::slice::from_ref(&line), 1.0);
|
||||
td.align_h = align(line.anchor);
|
||||
let mut local = path.clone();
|
||||
local.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
td.path = Some(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) });
|
||||
let transform = world * Affine::translate((bb.x0, bb.y0));
|
||||
add_layer(cx, t, &[line], td, transform, (bb.width().max(1.0), bb.height().max(1.0)), world, 1.0, parent);
|
||||
true
|
||||
}
|
||||
|
||||
fn align(a: TextAnchor) -> TextAlignH {
|
||||
match a {
|
||||
TextAnchor::Start => TextAlignH::Left,
|
||||
TextAnchor::Middle => TextAlignH::Center,
|
||||
TextAnchor::End => TextAlignH::Right,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The text layer for `block` (lines sharing an anchor, x and pitch).
|
||||
fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world: Affine, parent: NodeId) {
|
||||
let Some(first) = block.first() else { return };
|
||||
let mut td = text_data(block, s);
|
||||
td.align_h = align(first.anchor);
|
||||
// Placed by its layout (runs included): the anchor on x, the first baseline on y.
|
||||
let lay = figcraft_text::layout(&td, None, None);
|
||||
let (w, h) = (lay.width.ceil().max(1.0), lay.height.max(1.0));
|
||||
let baseline = lay.lines.first().map_or(0.0, |l| l.baseline);
|
||||
let transform = m * Affine::translate((s * first.x - anchor_k(first.anchor) * w, s * first.y - baseline));
|
||||
add_layer(cx, t, block, td, transform, (w, h), world, s, parent);
|
||||
}
|
||||
|
||||
/// Add text layer `td` of `block` at `transform` with box `(w, h)`, with the spans' fills and the
|
||||
/// first span's stroke (`world`: the SVG text's space; `s`: the scale folded into font sizes).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_layer(
|
||||
cx: &mut Ctx,
|
||||
t: &usvg::Text,
|
||||
block: &[Line],
|
||||
mut td: TextData,
|
||||
transform: Affine,
|
||||
(w, h): (f64, f64),
|
||||
world: Affine,
|
||||
s: f64,
|
||||
parent: NodeId,
|
||||
) {
|
||||
let Some(first) = block.first() else { return };
|
||||
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
|
||||
if spans.iter().all(|sp| !sp.is_visible()) {
|
||||
return;
|
||||
}
|
||||
let target = Target { to_node: transform.inverse() * world, size: (w, h) };
|
||||
let mut fills = Vec::with_capacity(spans.len());
|
||||
for sp in &spans {
|
||||
|
|
|
|||
|
|
@ -214,9 +214,50 @@ impl W<'_> {
|
|||
let op = if p.opacity < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(p.opacity))) } else { String::new() };
|
||||
Some(format!("fill=\"url(#{pid})\"{op}{blend}"))
|
||||
}
|
||||
PaintKind::Pattern(pt) => {
|
||||
let (tiles, cell) = self.pattern_tiles(pt, w, h)?;
|
||||
let pid = self.id("pattern");
|
||||
let _ = write!(
|
||||
self.defs,
|
||||
"<pattern id=\"{pid}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"translate({} {})\">{tiles}</pattern>",
|
||||
num(cell.size.0),
|
||||
num(cell.size.1),
|
||||
num(cell.origin.0),
|
||||
num(cell.origin.1)
|
||||
);
|
||||
let op = if p.opacity < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(p.opacity))) } else { String::new() };
|
||||
Some(format!("fill=\"url(#{pid})\"{op}{blend}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of pattern `pt` on a `w`×`h` box: the source's vectors at each tile, or (a source
|
||||
/// with effects, which draw as page-placed images) an image of the cell.
|
||||
fn pattern_tiles(&mut self, pt: &figcraft_color::PatternPaint, w: f64, h: f64) -> Option<(String, figcraft_color::PatternCell)> {
|
||||
let doc = self.doc;
|
||||
let src = doc.node(NodeId(pt.source)).filter(|n| !n.is_page())?;
|
||||
if doc.subtree(src.id).iter().filter_map(|i| doc.node(*i)).any(|n| n.effects.iter().any(|e| e.visible)) {
|
||||
let (pix, cell, _) = figcraft_render::pattern::cell_pixmap(doc, pt, w, h, RASTER_SCALE, 0.0)?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(figcraft_render::encode_png(&pix).ok()?);
|
||||
let img = format!(
|
||||
"<image width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" href=\"data:image/png;base64,{b64}\"/>",
|
||||
num(cell.size.0),
|
||||
num(cell.size.1)
|
||||
);
|
||||
return Some((img, cell));
|
||||
}
|
||||
let cell = pt.cell(src.width, src.height, w, h)?;
|
||||
let unrotated = src.transform.inverse();
|
||||
let page_body = std::mem::take(&mut self.body);
|
||||
let _ = figcraft_render::pattern::nested(|| {
|
||||
for (tx, ty) in &cell.tiles {
|
||||
self.node(src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0);
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
Some((std::mem::replace(&mut self.body, page_body), cell))
|
||||
}
|
||||
|
||||
/// The layer's filter: drop shadows (but those drawn as `exact` geometry: see
|
||||
/// [`W::drop_shadows`]) and layer blur.
|
||||
fn filter_for(&mut self, n: &Node, exact: bool) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ pub struct Glyph {
|
|||
pub style: usize,
|
||||
/// Part of a right-to-left run: its first char sits at the glyph's right edge.
|
||||
pub rtl: bool,
|
||||
/// Moves the placed glyph onto the text's path (identity without one).
|
||||
pub warp: Affine,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
|
|
@ -34,7 +36,7 @@ impl Glyph {
|
|||
pub fn path(&self) -> BezPath {
|
||||
let s = self.size / self.face.upem;
|
||||
let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone();
|
||||
p.apply_affine(Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
|
||||
p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
|
||||
p
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +78,8 @@ pub struct TextLayout {
|
|||
pub height: f64,
|
||||
/// Whether text was cut off (truncation).
|
||||
pub truncated: bool,
|
||||
/// Where the text sits on its path (text on a path).
|
||||
pub on_path: Option<crate::onpath::OnPath>,
|
||||
}
|
||||
|
||||
/// Ratio of Figma's "Auto" line height to the font size (font ascent+descent+gap, per face).
|
||||
|
|
@ -430,6 +434,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
|||
char_end: at,
|
||||
style: st_idx,
|
||||
rtl: false,
|
||||
warp: Affine::IDENTITY,
|
||||
});
|
||||
mx += adv;
|
||||
}
|
||||
|
|
@ -463,6 +468,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
|||
char_end: it.char_end,
|
||||
style: it.style,
|
||||
rtl: it.level % 2 == 1,
|
||||
warp: Affine::IDENTITY,
|
||||
});
|
||||
}
|
||||
pen += it.adv;
|
||||
|
|
@ -567,6 +573,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
|||
}
|
||||
}
|
||||
out.decorations = decos;
|
||||
if let Some(tp) = &text.path {
|
||||
crate::onpath::place(&mut out, tp, text.align_h);
|
||||
}
|
||||
let _ = LineHeight::Auto;
|
||||
out
|
||||
}
|
||||
|
|
@ -625,6 +634,7 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
|
|||
char_end: last.char_end,
|
||||
style: last.style,
|
||||
rtl: false,
|
||||
warp: Affine::IDENTITY,
|
||||
});
|
||||
if let Some(l) = out.lines.last_mut() {
|
||||
l.glyph_end = out.glyphs.len();
|
||||
|
|
@ -757,7 +767,8 @@ impl TextLayout {
|
|||
let glyphs = lines.flat_map(|(l, gs)| {
|
||||
gs.iter().map(move |g| {
|
||||
let pad = g.size.abs() * 0.5;
|
||||
Rect::new(g.x.min(g.x + g.advance) - pad, l.top - pad, g.x.max(g.x + g.advance) + pad, l.top + l.height + pad)
|
||||
let r = Rect::new(g.x.min(g.x + g.advance) - pad, l.top - pad, g.x.max(g.x + g.advance) + pad, l.top + l.height + pad);
|
||||
figcraft_geom::transform_rect_bbox(g.warp, r)
|
||||
})
|
||||
});
|
||||
glyphs.chain(self.decorations.iter().map(|d| d.rect)).filter(|r| r.x0.is_finite() && r.y1.is_finite()).reduce(|a, b| a.union(b))
|
||||
|
|
@ -826,8 +837,30 @@ impl TextLayout {
|
|||
}
|
||||
}
|
||||
|
||||
/// Layout point `(x, y)` where it shows: moved onto the path for text on a path.
|
||||
pub fn warp_point(&self, x: f64, y: f64) -> figcraft_geom::Point {
|
||||
let p = figcraft_geom::Point::new(x, y);
|
||||
let Some(on) = &self.on_path else { return p };
|
||||
let baseline = self.lines.iter().find(|l| y <= l.top + l.height).or(self.lines.last()).map_or(y, |l| l.baseline);
|
||||
on.frame(x, baseline).map_or(p, |f| f * p)
|
||||
}
|
||||
|
||||
/// Text on a path: the char nearest to where `(x, y)` shows, by the glyph it falls nearest to.
|
||||
fn hit_on_path(&self, x: f64, y: f64) -> usize {
|
||||
let p = figcraft_geom::Point::new(x, y);
|
||||
let near = self.glyphs.iter().filter(|g| g.char_end > g.char_start && g.warp.determinant().abs() > 1e-12).map(|g| {
|
||||
let q = g.warp.inverse() * p;
|
||||
let mid = g.x + g.advance / 2.0;
|
||||
((q - figcraft_geom::Point::new(mid, g.y - g.size / 3.0)).hypot(), if q.x < mid { g.char_start } else { g.char_end })
|
||||
});
|
||||
near.min_by(|a, b| a.0.total_cmp(&b.0)).map_or(0, |(_, i)| i)
|
||||
}
|
||||
|
||||
/// The char index nearest to `(x, y)` (layout coordinates).
|
||||
pub fn hit(&self, x: f64, y: f64) -> usize {
|
||||
if self.on_path.is_some() {
|
||||
return self.hit_on_path(x, y);
|
||||
}
|
||||
let Some((li, line)) =
|
||||
self.lines.iter().enumerate().find(|(_, l)| y < l.top + l.height).or_else(|| self.lines.iter().enumerate().next_back())
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
pub mod display;
|
||||
pub mod fontdb;
|
||||
mod layout;
|
||||
mod onpath;
|
||||
|
||||
pub use fontdb::{
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords,
|
||||
scan_system_fonts, system_fonts_scanned,
|
||||
};
|
||||
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
|
||||
pub use onpath::OnPath;
|
||||
|
||||
use figcraft_doc::{Node, NodeKind, TextAlignV, TextAutoResize};
|
||||
|
||||
|
|
@ -106,6 +108,10 @@ pub fn layout_cached(t: &figcraft_doc::TextData, w: Option<f64>, h: Option<f64>)
|
|||
/// offset that applies its vertical alignment.
|
||||
pub fn layout_node(n: &Node) -> Option<(std::sync::Arc<TextLayout>, f64)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
// Text on a path has no box: one line per paragraph, placed by its path.
|
||||
if t.path.is_some() {
|
||||
return Some((layout_cached(t, None, None), 0.0));
|
||||
}
|
||||
let l = layout_cached(t, Some(n.width), Some(n.height));
|
||||
let dy = match (t.auto_resize, t.align_v) {
|
||||
(TextAutoResize::WidthAndHeight | TextAutoResize::Height, _) => 0.0,
|
||||
|
|
@ -119,6 +125,9 @@ pub fn layout_node(n: &Node) -> Option<(std::sync::Arc<TextLayout>, f64)> {
|
|||
/// The box size a text node should have after its content or style changed, per its resize mode.
|
||||
pub fn auto_size(n: &Node) -> Option<(f64, f64)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
if t.path.is_some() {
|
||||
return None;
|
||||
}
|
||||
match t.auto_resize {
|
||||
TextAutoResize::WidthAndHeight => {
|
||||
let l = layout_cached(t, None, None);
|
||||
|
|
@ -137,6 +146,8 @@ mod cache_tests;
|
|||
#[cfg(test)]
|
||||
mod list_tests;
|
||||
#[cfg(test)]
|
||||
mod onpath_tests;
|
||||
#[cfg(test)]
|
||||
mod rtl_list_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
//! Text on a path: each glyph of a laid-out text turned and moved onto the path, its baseline on
|
||||
//! the path at the point its advance centre falls on (see [`figcraft_doc::TextPath`]).
|
||||
|
||||
use figcraft_doc::{TextAlignH, TextPath};
|
||||
use figcraft_geom::measure::{Polyline, polylines};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use crate::TextLayout;
|
||||
|
||||
/// Where a laid-out text sits on its path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OnPath {
|
||||
// shortcut: the text follows the path's first subpath only; follow the next ones when a use shows up.
|
||||
line: Polyline,
|
||||
/// Path length before layout x = 0 (start offset and alignment).
|
||||
origin: f64,
|
||||
flip: bool,
|
||||
/// The first line's baseline: layout y that sits on the path.
|
||||
baseline: f64,
|
||||
}
|
||||
|
||||
impl OnPath {
|
||||
/// Text on `tp` whose layout x = 0 sits `shift` before the start point and whose layout y
|
||||
/// `baseline` sits on the path. `None` for an empty or unmeasurable path.
|
||||
pub fn new(tp: &TextPath, shift: f64, baseline: f64) -> Option<Self> {
|
||||
let mut line = polylines(&tp.path())?.into_iter().next()?;
|
||||
// A subpath ending where it starts (an ellipse) goes round like a closed one.
|
||||
line.closed |= matches!((line.points.first(), line.points.last()), (Some(a), Some(b)) if (*a - *b).hypot() < 1e-6);
|
||||
Some(Self { origin: tp.start() * line.length() - shift, line, flip: tp.flip, baseline })
|
||||
}
|
||||
|
||||
/// The point on the path `along` units from its start (flipped: from its end) and the
|
||||
/// direction there. `None` past an open path's ends.
|
||||
fn at(&self, along: f64) -> Option<(Point, Vec2)> {
|
||||
let len = self.line.length();
|
||||
if len <= 1e-9 || !along.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let s = if self.line.closed { along.rem_euclid(len) } else { along };
|
||||
if !(0.0..=len).contains(&s) {
|
||||
return None;
|
||||
}
|
||||
let t = if self.flip { 1.0 - s / len } else { s / len };
|
||||
let (p, n) = self.line.at(t)?;
|
||||
// The normal is the direction turned 90° (y down): the direction is the normal turned back.
|
||||
let dir = Vec2::new(n.y, -n.x);
|
||||
Some((p, if self.flip { -dir } else { dir }))
|
||||
}
|
||||
|
||||
/// The transform placing what lies around layout point `(x, y)` on the path: `(x, y)` lands
|
||||
/// `y - baseline` below the path point `x` along, turned with the path. `None` past an open
|
||||
/// path's ends.
|
||||
pub fn frame(&self, x: f64, y: f64) -> Option<Affine> {
|
||||
let (p, dir) = self.at(self.origin + x)?;
|
||||
let below = Vec2::new(-dir.y, dir.x) * (y - self.baseline);
|
||||
Some(Affine::translate((p + below).to_vec2()) * Affine::rotate(dir.atan2()) * Affine::translate((-x, -y)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `out` (laid out without a box) on `tp`. Glyphs past an open path's ends are hidden;
|
||||
/// decorations are dropped.
|
||||
pub(crate) fn place(out: &mut TextLayout, tp: &TextPath, align: TextAlignH) {
|
||||
let shift = match align {
|
||||
TextAlignH::Left | TextAlignH::Justified => 0.0,
|
||||
TextAlignH::Center => out.width / 2.0,
|
||||
TextAlignH::Right => out.width,
|
||||
};
|
||||
let Some(on) = OnPath::new(tp, shift, out.lines.first().map_or(0.0, |l| l.baseline)) else {
|
||||
out.glyphs.clear();
|
||||
out.decorations.clear();
|
||||
return;
|
||||
};
|
||||
for g in &mut out.glyphs {
|
||||
// A hidden glyph collapses to a point.
|
||||
g.warp = on.frame(g.x + g.advance / 2.0, g.y).unwrap_or(Affine::scale(0.0));
|
||||
}
|
||||
// shortcut: underline and strikethrough aren't drawn on a path; bend them along when asked for.
|
||||
out.decorations.clear();
|
||||
out.on_path = Some(on);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
//! Text on a path: points land on the path turned with it, flipped runs the other way, glyphs
|
||||
//! past an open path's end hide and a closed path wraps.
|
||||
|
||||
use figcraft_doc::{TextAutoResize, TextData, TextPath};
|
||||
use figcraft_geom::{BezPath, Point};
|
||||
|
||||
use crate::OnPath;
|
||||
|
||||
fn tp(d: &str, start: f64, flip: bool) -> TextPath {
|
||||
TextPath { d: d.into(), start, flip }
|
||||
}
|
||||
|
||||
fn near(a: Point, b: (f64, f64)) -> bool {
|
||||
(a.x - b.0).abs() < 1e-6 && (a.y - b.1).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_follow_the_path() {
|
||||
// Halfway along a horizontal line, baseline at y = 10.
|
||||
let on = OnPath::new(&tp("M0 0 L100 0", 0.5, false), 0.0, 10.0).unwrap();
|
||||
let f = on.frame(0.0, 10.0).unwrap();
|
||||
assert!(near(f * Point::new(0.0, 10.0), (50.0, 0.0)));
|
||||
assert!(near(f * Point::new(0.0, 0.0), (50.0, -10.0)), "above the baseline is above the path");
|
||||
// Down a vertical line, x runs along y.
|
||||
let on = OnPath::new(&tp("M0 0 L0 100", 0.0, false), 0.0, 0.0).unwrap();
|
||||
let f = on.frame(10.0, 0.0).unwrap();
|
||||
assert!(near(f * Point::new(10.0, 0.0), (0.0, 10.0)));
|
||||
assert!(near(f * Point::new(11.0, 0.0), (0.0, 11.0)));
|
||||
// Flipped: from the other end, on the other side.
|
||||
let on = OnPath::new(&tp("M0 0 L100 0", 0.0, true), 0.0, 10.0).unwrap();
|
||||
let f = on.frame(0.0, 10.0).unwrap();
|
||||
assert!(near(f * Point::new(0.0, 10.0), (100.0, 0.0)));
|
||||
assert!(near(f * Point::new(0.0, 0.0), (100.0, 10.0)));
|
||||
// Centred text starts half its width earlier.
|
||||
let on = OnPath::new(&tp("M0 0 L100 0", 0.5, false), 20.0, 0.0).unwrap();
|
||||
assert!(near(on.frame(0.0, 0.0).unwrap() * Point::new(0.0, 0.0), (30.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_end_and_closed_ones_wrap() {
|
||||
let open = OnPath::new(&tp("M0 0 L100 0", 0.0, false), 0.0, 0.0).unwrap();
|
||||
assert!(open.frame(150.0, 0.0).is_none() && open.frame(-1.0, 0.0).is_none());
|
||||
let closed = OnPath::new(&tp("M0 0 L100 0 L100 100 L0 100 Z", 0.0, false), 0.0, 0.0).unwrap();
|
||||
assert!(near(closed.frame(410.0, 0.0).unwrap() * Point::new(410.0, 0.0), (10.0, 0.0)), "wrapped past the start");
|
||||
assert!(OnPath::new(&tp("not a path", 0.0, false), 0.0, 0.0).is_none());
|
||||
assert!(OnPath::new(&tp("", f64::NAN, false), 0.0, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laid_out_glyphs_sit_on_the_path() {
|
||||
if crate::db().write().unwrap_or_else(std::sync::PoisonError::into_inner).default_face().is_none() {
|
||||
eprintln!("no fonts; skipping");
|
||||
return;
|
||||
}
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((0.0, 1000.0));
|
||||
let mut t = TextData::new("Hello");
|
||||
t.style.font_size = 20.0;
|
||||
t.path = Some(TextPath::new(&path));
|
||||
let l = crate::layout(&t, None, None);
|
||||
// Down the vertical line: every glyph is turned 90° and lies along x = 0.
|
||||
let ink = l.ink_bounds().unwrap();
|
||||
assert!(ink.width() < 60.0 && ink.height() > 40.0, "{ink:?}");
|
||||
assert!(l.glyphs.iter().all(|g| (g.warp.as_coeffs()[1] - 1.0).abs() < 1e-6));
|
||||
// Clicking near the third glyph puts the caret by it.
|
||||
let g = &l.glyphs[2];
|
||||
let mid = g.warp * Point::new(g.x + g.advance * 0.25, g.y - 5.0);
|
||||
assert_eq!(l.hit(mid.x, mid.y), g.char_start);
|
||||
// A text node on a path keeps its box.
|
||||
let mut n = figcraft_doc::Node::new(figcraft_doc::NodeId(1), figcraft_doc::NodeKind::Text(t), "t", 10.0, 1000.0);
|
||||
if let figcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = TextAutoResize::WidthAndHeight;
|
||||
}
|
||||
assert!(crate::auto_size(&n).is_none());
|
||||
}
|
||||
|
|
@ -505,9 +505,10 @@ impl TextOnScreen {
|
|||
Some(Self { layout, chars: t.char_count(), dy, world: doc.world_transform(id) })
|
||||
}
|
||||
|
||||
/// Layout point `(x, y)` on screen.
|
||||
/// Layout point `(x, y)` on screen (on the path, for text on a path).
|
||||
pub(crate) fn at(&self, app: &FigApp, x: f64, y: f64) -> Pos2 {
|
||||
let p = self.world * Point::new(x, y + self.dy);
|
||||
let q = self.layout.warp_point(x, y);
|
||||
let p = self.world * Point::new(q.x, q.y + self.dy);
|
||||
app.to_screen(p.x, p.y)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders,
|
||||
//! hex and RGB fields, gradient stops, image fills and document colours.
|
||||
//! hex and RGB fields, gradient stops, image and pattern fills and document colours.
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_color::{
|
||||
Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile,
|
||||
};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -154,7 +156,7 @@ fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Colo
|
|||
s.color = c;
|
||||
}
|
||||
}
|
||||
PaintKind::Image(_) => return,
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => return,
|
||||
}
|
||||
write(app, target, paint, false);
|
||||
}
|
||||
|
|
@ -400,6 +402,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
(Icon::Rotate, "Angular"),
|
||||
(Icon::Instance, "Diamond"),
|
||||
(Icon::Image, "Image"),
|
||||
(Icon::ViewGrid, "Pattern"),
|
||||
];
|
||||
let active = match &paint.kind {
|
||||
PaintKind::Solid { .. } => 0,
|
||||
|
|
@ -410,10 +413,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
GradientKind::Diamond => 4,
|
||||
},
|
||||
PaintKind::Image(_) => 5,
|
||||
PaintKind::Pattern(_) => 6,
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 34.0) {
|
||||
if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
|
||||
let base = paint.preview_color();
|
||||
paint.kind = match i {
|
||||
0 => PaintKind::Solid { color: base.with_alpha(1.0) },
|
||||
|
|
@ -433,6 +437,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
None => paint.kind.clone(),
|
||||
}
|
||||
}
|
||||
6 => match pattern_sources(app, &target).first() {
|
||||
Some((id, _)) => Paint::pattern(id.0).kind,
|
||||
None => {
|
||||
app.toast("Add a layer or component to tile first".to_string());
|
||||
paint.kind.clone()
|
||||
}
|
||||
},
|
||||
k => {
|
||||
let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
|
||||
match &paint.kind {
|
||||
|
|
@ -620,6 +631,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
});
|
||||
}
|
||||
}
|
||||
if let PaintKind::Pattern(pt) = paint.kind.clone() {
|
||||
pattern_controls(app, ui, &target, &mut paint, pt);
|
||||
}
|
||||
if let Some(color) = edit_color {
|
||||
let mut hsb = ui
|
||||
.data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb")))
|
||||
|
|
@ -680,7 +694,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
s.color = nc;
|
||||
}
|
||||
}
|
||||
PaintKind::Image(_) => {}
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => {}
|
||||
}
|
||||
write(app, &target, &paint, !done);
|
||||
}
|
||||
|
|
@ -729,6 +743,121 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
let _ = Pos2::ZERO;
|
||||
}
|
||||
|
||||
/// Layers a pattern can tile: the file's components and the current page's top-level layers, but
|
||||
/// the painted layers, what they contain and what contains them (at most 64).
|
||||
fn pattern_sources(app: &FigApp, target: &PickerTarget) -> Vec<(NodeId, String)> {
|
||||
let Ok(doc) = app.session.doc() else { return Vec::new() };
|
||||
let painted = match target {
|
||||
PickerTarget::Paint { ids, .. } => ids.clone(),
|
||||
PickerTarget::TextRange { id, .. } => vec![*id],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let top = app.session.page().ok().and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default();
|
||||
let comps = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.id);
|
||||
let mut out: Vec<(NodeId, String)> = Vec::new();
|
||||
for id in comps.chain(top) {
|
||||
let related = painted.iter().any(|t| doc.is_ancestor(*t, id) || doc.is_ancestor(id, *t));
|
||||
if !related
|
||||
&& !out.iter().any(|(o, _)| *o == id)
|
||||
&& let Some(n) = doc.node(id)
|
||||
{
|
||||
out.push((id, n.name.clone()));
|
||||
}
|
||||
if out.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pattern tab: source, tile type, alignment, scale and spacing.
|
||||
fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &mut Paint, mut pt: PatternPaint) {
|
||||
use widgets::{FieldEdit, Lead};
|
||||
// Some(live): changed, as part of a drag when live.
|
||||
let mut changed: Option<bool> = None;
|
||||
let name = app.session.doc().ok().and_then(|d| d.node(NodeId(pt.source)).map(|n| n.name.clone())).unwrap_or_else(|| "Missing layer".into());
|
||||
let sources = pattern_sources(app, target);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let resp = widgets::elided_dropdown(ui, &name, 208.0);
|
||||
crate::a11y::combo(&resp, "Pattern source", &name, crate::a11y::menu_open(&resp));
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for (id, label) in &sources {
|
||||
if crate::right::menu_check(ui, id.0 == pt.source, label, "") {
|
||||
pt.source = id.0;
|
||||
changed = Some(false);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let tiles = [("Grid", PatternTile::Grid), ("Horizontal hex", PatternTile::HorizontalHex), ("Vertical hex", PatternTile::VerticalHex)];
|
||||
let label = tiles.iter().find(|t| t.1 == pt.tile).map_or("Grid", |t| t.0);
|
||||
let resp = widgets::dropdown(ui, label, 120.0, Some(Icon::ViewGrid));
|
||||
crate::a11y::combo(&resp, "Tile type", label, crate::a11y::menu_open(&resp));
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in tiles {
|
||||
if crate::right::menu_check(ui, v == pt.tile, l, "") {
|
||||
pt.tile = v;
|
||||
changed = Some(false);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
let aligns = [("Start", PatternAlign::Start), ("Center", PatternAlign::Center), ("End", PatternAlign::End)];
|
||||
let label = aligns.iter().find(|a| a.1 == pt.align).map_or("Start", |a| a.0);
|
||||
let resp = widgets::dropdown(ui, label, 80.0, None);
|
||||
crate::a11y::combo(&resp, "Pattern alignment", label, crate::a11y::menu_open(&resp));
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in aligns {
|
||||
if crate::right::menu_check(ui, v == pt.align, l, "") {
|
||||
pt.align = v;
|
||||
changed = Some(false);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let field = |ui: &mut Ui, key: &str, lead: Lead, value: f64, suffix: &str| match widgets::number_field(
|
||||
ui,
|
||||
Id::new(key),
|
||||
lead,
|
||||
Some(value),
|
||||
suffix,
|
||||
64.0,
|
||||
1.0,
|
||||
) {
|
||||
Some(FieldEdit::Set(v)) => Some((v, false)),
|
||||
Some(FieldEdit::Scrub(v, end)) => Some((v, !end)),
|
||||
None => None,
|
||||
};
|
||||
if let Some((v, live)) = field(ui, "cp_pattern_scale", Lead::Icon(Icon::Scale, "Pattern scale"), pt.scale * 100.0, "%") {
|
||||
let (lo, hi) = PatternPaint::SCALE_RANGE;
|
||||
pt.scale = (v / 100.0).clamp(lo, hi);
|
||||
changed = Some(live);
|
||||
}
|
||||
if let Some((v, live)) = field(ui, "cp_pattern_gap_x", Lead::Icon(Icon::PaddingH, "Horizontal spacing"), pt.spacing.0, "") {
|
||||
pt.spacing.0 = v.clamp(0.0, PatternPaint::MAX_SPACING);
|
||||
changed = Some(live);
|
||||
}
|
||||
if let Some((v, live)) = field(ui, "cp_pattern_gap_y", Lead::Icon(Icon::PaddingV, "Vertical spacing"), pt.spacing.1, "") {
|
||||
pt.spacing.1 = v.clamp(0.0, PatternPaint::MAX_SPACING);
|
||||
changed = Some(live);
|
||||
}
|
||||
});
|
||||
if let Some(live) = changed {
|
||||
paint.kind = PaintKind::Pattern(pt);
|
||||
write(app, target, paint, live);
|
||||
if !live {
|
||||
finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the image preset row was asked to do.
|
||||
enum PresetAction {
|
||||
Apply(String),
|
||||
|
|
|
|||
|
|
@ -1387,6 +1387,7 @@ fn paint_label(p: &Paint) -> String {
|
|||
PaintKind::Solid { color } => color.to_hex(),
|
||||
PaintKind::Gradient(g) => format!("{:?}", g.kind),
|
||||
PaintKind::Image(_) => "Image".into(),
|
||||
PaintKind::Pattern(_) => "Pattern".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,43 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
|
|||
font_rows(app, ui, &ids, &styles, editing, &apply_style);
|
||||
}
|
||||
paragraph_rows(app, ui, doc, &ids, &styles, &apply_style);
|
||||
path_row(app, ui, doc, sel, &ids);
|
||||
}
|
||||
|
||||
/// Text on a path: where the text starts, which side it runs on, and taking it off the path; or,
|
||||
/// with a text and a path selected, putting the text on the path.
|
||||
fn path_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ids: &[NodeId]) {
|
||||
let paths: Option<Vec<(f64, bool)>> =
|
||||
ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).and_then(|t| t.path.as_ref()).map(|p| (p.start(), p.flip))).collect();
|
||||
let Some(paths) = paths else {
|
||||
let other = sel.iter().find(|i| !ids.contains(i)).and_then(|i| doc.node(*i));
|
||||
if ids.len() == 1 && sel.len() == 2 && other.is_some_and(|n| figcraft_doc::geometry::stroke_geometry(n).is_some()) {
|
||||
row(ui, 32.0, |ui| {
|
||||
if widgets::button(ui, "Put text on path", false, true).clicked() {
|
||||
app.run("text.onPath", json!({ "ids": sel }));
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
let start = paths.first().map(|p| p.0).filter(|s| paths.iter().all(|p| p.0 == *s));
|
||||
let flip = paths.iter().all(|p| p.1);
|
||||
row(ui, 32.0, |ui| {
|
||||
let lead = Lead::Icon(Icon::Pen, "Start on path");
|
||||
match widgets::number_field(ui, Id::new("ty_path_start"), lead, start.map(|s| s * 100.0), "%", 96.0, 1.0) {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
app.run("text.pathOptions", json!({"ids": ids, "start": v / 100.0}));
|
||||
}
|
||||
Some(FieldEdit::Scrub(v, done)) => live(app, "Start on path", "text.pathOptions", json!({"ids": ids, "start": v / 100.0}), done),
|
||||
None => {}
|
||||
}
|
||||
if icons::button(ui, Icon::FlipV, 24.0, flip, "Flip text to the other side").clicked() {
|
||||
app.run("text.pathOptions", json!({"ids": ids, "flip": !flip}));
|
||||
}
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, "Remove text from path").clicked() {
|
||||
app.run("text.removeFromPath", json!({ "ids": ids }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set character `props` on the selected chars of the text being edited, else on whole layers
|
||||
|
|
|
|||
|
|
@ -672,7 +672,7 @@ pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response
|
|||
resp
|
||||
}
|
||||
|
||||
/// A paint as a screen reader says it: "#FF0000", "#FF0000 50%", "Gradient", "Image".
|
||||
/// A paint as a screen reader says it: "#FF0000", "#FF0000 50%", "Gradient", "Image", "Pattern".
|
||||
pub fn paint_name(paint: &figcraft_color::Paint) -> String {
|
||||
use figcraft_color::PaintKind;
|
||||
match &paint.kind {
|
||||
|
|
@ -683,6 +683,7 @@ pub fn paint_name(paint: &figcraft_color::Paint) -> String {
|
|||
}
|
||||
PaintKind::Gradient(_) => "Gradient".into(),
|
||||
PaintKind::Image(_) => "Image".into(),
|
||||
PaintKind::Pattern(_) => "Pattern".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -711,9 +712,10 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color:
|
|||
clip.rect_filled(Rect::from_min_max(pos2(x0, rect.min.y), pos2(x1, rect.max.y)), CornerRadius::ZERO, c);
|
||||
}
|
||||
}
|
||||
PaintKind::Image(_) => {
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => {
|
||||
let icon = if matches!(paint.kind, PaintKind::Image(_)) { Icon::Image } else { Icon::ViewGrid };
|
||||
clip.rect_filled(rect, CornerRadius::ZERO, Color32::from_gray(0xB3));
|
||||
icons::paint(&clip, rect.shrink(2.0), Icon::Image, Color32::WHITE);
|
||||
icons::paint(&clip, rect.shrink(2.0), icon, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ alpha.
|
|||
|
||||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on a path as outlines; fuzzed | 2–4 (filters) |
|
||||
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) |
|
||||
| PNG, JPG | yes | yes | | — |
|
||||
| GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — |
|
||||
| WebP | yes | yes | | — |
|
||||
|
|
|
|||
19
docs/gaps.md
19
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -22,7 +22,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 6 | Plugins: no run-time UI, distribution or widgets; Figma's plugins don't run | Ecosystem | 25–55 | [target-app-parity](target-app-parity.md) |
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
| 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) |
|
||||
| 9 | Figma Draw: stretch and scatter brushes, pen pressure (text on a path and pattern fills landed) | Feature | 5–10 | [target-app-parity](target-app-parity.md) |
|
||||
| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) |
|
||||
| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) |
|
||||
| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
|
|
@ -137,13 +137,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
desktop app).
|
||||
- **Estimate:** 10–20 h, partly human (hardware).
|
||||
|
||||
### 9. Figma Draw: brushes, pressure, text on a path, pattern fills
|
||||
- **Missing:** stretch and scatter brushes, pen pressure for the pencil and brushes, text on a
|
||||
path, pattern fills, multi-edit.
|
||||
- **Evidence:** `PaintKind` has solid, gradient and image only (`crates/color/src/lib.rs`); no
|
||||
pressure input anywhere; SVG text on a path imports as outlines.
|
||||
### 9. Figma Draw: brushes and pressure
|
||||
- **Missing:** stretch and scatter brushes, pen pressure for the pencil and brushes, multi-edit.
|
||||
- **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the
|
||||
Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `<textPath>` imports
|
||||
as text on a path) and pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's
|
||||
Pattern tab; SVG `<pattern>`, PDF tiling patterns).
|
||||
- **Evidence:** no pressure input anywhere; strokes have uniform and variable widths only.
|
||||
- **Impact:** illustration-flavoured work moved into Figma Draw in 2025.
|
||||
- **Estimate:** 10–18 h.
|
||||
- **Estimate:** 5–10 h.
|
||||
|
||||
### 10. Team libraries: no team-wide lists or accounts; analytics are counts
|
||||
- **Missing:** libraries listed for a whole team or organization (each file enables a library by
|
||||
|
|
@ -258,6 +260,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
|
|
|||
27
docs/mcp.md
27
docs/mcp.md
|
|
@ -452,6 +452,30 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap
|
|||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"ids":[2,3],"points":[[120,80],[160,80]]}}}}
|
||||
```
|
||||
|
||||
### Text on a path and pattern fills
|
||||
|
||||
- **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer
|
||||
and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the
|
||||
path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back).
|
||||
The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment
|
||||
places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side
|
||||
the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps
|
||||
them around; only the first subpath is followed. `text.pathOptions {start?, flip?, ids?}` changes
|
||||
them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer
|
||||
scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows
|
||||
`textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines
|
||||
(PDF too: not selectable); underline and strikethrough aren't drawn on a path.
|
||||
- **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex|
|
||||
verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at
|
||||
`index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing`
|
||||
between tiles; hex tiles shift every other row (column) by half a tile; `align` puts a tile in the
|
||||
box's top-left corner, centre or bottom-right corner. The fill is `{type: "pattern", source,
|
||||
tile, scale, spacing, align}` and can also be set with `fill.set {paint}`. It updates as the
|
||||
source changes; a layer can't tile itself or a layer containing it (a pattern nested more than
|
||||
two deep draws nothing). Copies made together with their source tile the copied source. Exports:
|
||||
SVG `<pattern>` and PDF tiling patterns of the source's vectors (a source with effects as an image
|
||||
of the tile); the canvas draws one tile at the zoom it shows (at most 2048 px a side) and repeats it.
|
||||
|
||||
### Importing SVG
|
||||
|
||||
`open_file {path}` opens an SVG as a new document; `run_command {command: "file.import", params:
|
||||
|
|
@ -466,7 +490,8 @@ and masks as luminance or alpha masks; shadow and blur filters (`feDropShadow`,
|
|||
flood / composite chains, colour-matrix shadows, inner shadows, `feMorphology` spread) as drop
|
||||
shadows, inner shadows and layer blurs; opacity and `mix-blend-mode`; text as text layers (fonts
|
||||
the SVG names when installed, anchors, letter spacing, styled `<tspan>` runs, stacked lines as one
|
||||
layer); images as image fills, and SVG images imported in place. CSS, `<use>`/`<symbol>`, units
|
||||
layer; text on one `<textPath>` as text on a path, with its start offset and anchor); images as
|
||||
image fills, and SVG images imported in place. CSS, `<use>`/`<symbol>`, units
|
||||
and `viewBox`/`preserveAspectRatio` are resolved. The result's `warnings` list what was left out
|
||||
(filters that aren't shadows or blurs, rotated repeating patterns, a clip path's own clip path).
|
||||
Linked files are never read, only data URIs; documents nested more than 512 elements deep are
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -50,7 +50,7 @@ blockers.
|
|||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass |
|
||||
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
|
||||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
|
||||
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
|
||||
| M4 Vector & text depth | ~78% (live booleans, arc fields, shape builder on strokes, one-sided widths, text on a path and pattern fills landed 2026-10-11) | spell check, Figma Draw (brushes, pressure), boolean hit testing on the result | 13–24 |
|
||||
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
|
||||
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent, Code Connect | 40–80 |
|
||||
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
|
||||
|
|
@ -65,13 +65,14 @@ blockers.
|
|||
- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after
|
||||
accounts), branching (8–15 h).
|
||||
- GPU canvas held to the CPU renderer (30–60 h).
|
||||
- Figma Draw tools (10–18 h).
|
||||
- Figma Draw brushes and pen pressure (5–10 h).
|
||||
- Mobile viewer and prototype mirror (20–40 h).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
|
||||
| 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 |
|
||||
| 10 | Text | 8 | 85% | 64% | Spell check, text on a path, CJK text QA, real-file text metrics | 8–14 |
|
||||
| 10 | Text | 8 | 85% | 64% | Spell check, CJK text QA, real-file text metrics (text on a path landed) | 7–13 |
|
||||
| 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 |
|
||||
| 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 |
|
||||
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
|
||||
|
|
@ -113,7 +113,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
|
||||
| 20 | Figma Draw extras | 2 | 65% | 45% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills. Missing: brushes (stretch/scatter), pressure, multi-edit | 5–10 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **206–411** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 86–168 |
|
||||
|
|
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ interaction feel not yet compared.
|
|||
| Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check |
|
||||
| Eyedropper (I) | done | |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
| Brushes, text on a path (Figma Draw) | missing | |
|
||||
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
|
||||
| Brushes (Figma Draw) | missing | |
|
||||
| Image crop handles | done | |
|
||||
|
||||
## Canvas
|
||||
|
|
|
|||
Loading…
Reference in New Issue