diff --git a/ROADMAP.md b/ROADMAP.md index 6d5de4ad..b4dcba4b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -93,7 +93,7 @@ native speakers' reviews of the nine language drafts need people. | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~37% | ~21% | 28–56 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. @@ -327,6 +328,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs index 28ed0c1d..ab943d2a 100644 --- a/crates/doc/src/board.rs +++ b/crates/doc/src/board.rs @@ -7,7 +7,7 @@ use figcraft_color::Color; use figcraft_geom::kurbo::{Ellipse, RoundedRect}; -use figcraft_geom::{BezPath, Point, Rect, Shape}; +use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2}; use serde::{Deserialize, Serialize}; use crate::NodeId; @@ -341,8 +341,11 @@ impl Stamp { pub enum Routing { #[default] Straight, - /// Right angles only: out of each end along its side's normal, turning once or twice. + /// Right angles only: out of each end along its side's normal, turning once or twice, around + /// the objects it joins when the short way would cross them. Elbow, + /// One smooth curve, leaving and arriving along the sides' normals. + Curved, } impl Routing { @@ -350,10 +353,11 @@ impl Routing { match self { Routing::Straight => "straight", Routing::Elbow => "elbow", + Routing::Curved => "curved", } } pub fn from_id(s: &str) -> Option { - [Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) + [Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) } } @@ -397,6 +401,17 @@ impl Side { pub fn horizontal(self) -> bool { matches!(self, Side::Left | Side::Right) } + + /// The way out of the side (y down; none for [`Side::Auto`]). + pub fn normal(self) -> Vec2 { + match self { + Side::Top => Vec2::new(0.0, -1.0), + Side::Right => Vec2::new(1.0, 0.0), + Side::Bottom => Vec2::new(0.0, 1.0), + Side::Left => Vec2::new(-1.0, 0.0), + Side::Auto => Vec2::ZERO, + } + } } /// One end of a connector: attached to a layer, or a free point on the page. diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs index 317b0889..4dd76bbd 100644 --- a/crates/engine/src/board.rs +++ b/crates/engine/src/board.rs @@ -12,7 +12,8 @@ use figcraft_doc::{ HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, }; -use figcraft_geom::{Affine, Point, Rect}; +use figcraft_geom::kurbo::PathEl; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; @@ -98,10 +99,17 @@ pub fn specs() -> Vec { spec!( "board.connector", "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", true, connector_cmd ), + spec!( + "board.reattach", + "Reattach connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + true, + reattach + ), spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), spec!( "board.stamp", @@ -127,7 +135,7 @@ pub fn specs() -> Vec { spec!( "board.setStyle", "Board style", - "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", true, set_style ), @@ -143,7 +151,7 @@ pub fn specs() -> Vec { } board_tool(s, ToolId::BoardShape) }), - spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| { + spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { if let Some(v) = string(p, "style") { s.tool.board.routing = routing_of("tool.connector", &v)?; } @@ -205,7 +213,7 @@ fn stamp_of(cmd: &str, v: &str) -> Result { } fn routing_of(cmd: &str, v: &str) -> Result { - Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`"))) + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) } fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { @@ -606,7 +614,7 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result { n.stroke.cap_end = end; n.board = Some(BoardItem::Connector(c)); })?; - place_route(doc, id, &pts); + place_route(doc, id, &pts, routing); let mut out = json!({"id": id}); if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { out["label"] = json!(set_label(doc, id, &text)?); @@ -615,6 +623,80 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The connector `id` names: a connector, or the label on one. +fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { + match doc.get(id)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((id, c)), + Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((connector, c)), + _ => Err(bad(cmd, "that isn't a connector")), + }, + _ => Err(bad(cmd, "that isn't a connector")), + } +} + +fn reattach(s: &mut Session, p: &Value) -> Result { + let cmd = "board.reattach"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, mut c) = connector_of(cmd, s.doc()?, id)?; + let from = match string(p, "end").as_deref() { + Some("from") => true, + Some("to") => false, + _ => return Err(bad(cmd, "`end` is from or to")), + }; + let end = end_param(cmd, s, p, "target", "side")?; + let other = if from { c.to } else { c.from }; + if end.node().is_some() && end.node() == other.node() { + return Err(bad(cmd, "connect two different layers")); + } + *(if from { &mut c.from } else { &mut c.to }) = end; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); + place_route(doc, id, &pts, c.routing); + Ok(json!({"id": id})) +} + +/// A connector end as `board.connector` and `board.reattach` take it. +fn end_json(e: End) -> Value { + match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + } +} + +/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. +fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { + match &doc.node(id)?.board { + Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), + _ => None, + } +} + +/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. +pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { + let [id] = sel else { return None }; + if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { + return None; + } + let (a, b) = ends_now(doc, *id)?; + Some((*id, [a, b])) +} + +/// How near (screen pixels) a press must be to a selected connector's end to drag it. +pub const END_HANDLE_PX: f64 = 8.0; + +/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). +pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { + let (id, [a, b]) = selected_connector_ends(doc, sel)?; + let (da, db) = ((a - p).hypot(), (b - p).hypot()); + (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) +} + /// The label of connector `id`, if it has one. pub fn label_of(doc: &Document, id: NodeId) -> Option { let parent = doc.node(id)?.parent?; @@ -664,12 +746,7 @@ fn label_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "board.label"; require(s, cmd)?; let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let doc = s.doc()?; - let id = match doc.node(id).and_then(|n| n.board.clone()) { - Some(BoardItem::Connector(_)) => id, - Some(BoardItem::Label { connector }) => connector, - _ => return Err(bad(cmd, "that isn't a connector")), - }; + let (id, _) = connector_of(cmd, s.doc()?, id)?; let text = req_string(cmd, p, "text")?; let label = set_label(s.doc_mut()?, id, &text)?; Ok(json!({"id": id, "label": label})) @@ -941,7 +1018,7 @@ pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTr n.board = Some(BoardItem::Connector(c)); } if let Some(pts) = route(doc, &c) { - place_route(doc, id, &pts); + place_route(doc, id, &pts, c.routing); } } for (label, connector) in labels { @@ -974,7 +1051,7 @@ fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { return false; } - let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false }; + let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; let d = (doc.world_transform(id) * Point::ZERO) - expected; if d.hypot() <= 1e-9 { return false; @@ -1017,7 +1094,12 @@ fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { fn route_mid(doc: &Document, id: NodeId) -> Option { let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; let w = doc.world_transform(id); - let pts: Vec = network.vertices.iter().map(|v| w * v.point()).collect(); + let mut pts: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(w * p); + } + }); let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); let mut left = total / 2.0; for s in pts.windows(2) { @@ -1031,19 +1113,42 @@ fn route_mid(doc: &Document, id: NodeId) -> Option { pts.first().copied() } -/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched -/// when it already runs there, so a connector that didn't move isn't an edit. -fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { +/// The path through a route's page points `pts` (see [`route`]): one curve for a curved +/// connector, straight runs otherwise. +fn route_path(pts: &[Point], routing: Routing) -> BezPath { + let mut path = BezPath::new(); + match (routing, pts) { + (Routing::Curved, [p0, c0, c1, p1]) => { + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + } + _ => { + for (i, p) in pts.iter().enumerate() { + if i == 0 { path.move_to(*p) } else { path.line_to(*p) } + } + } + } + path +} + +/// The box a path is drawn in, when it is finite. +fn finite_box(path: &BezPath) -> Option { + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and +/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. +fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { let Some(n) = doc.node(id) else { return }; let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let inv = pw.inverse(); - let local: Vec = pts.iter().map(|p| inv * *p).collect(); - let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return }; - if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { - return; - } - let rel: Vec = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect(); - let network = VectorNetwork::from_points(&rel, false); + let local = pw.inverse() * route_path(pts, routing); + let Some(b) = finite_box(&local) else { return }; + let rel = Affine::translate((-b.x0, -b.y0)) * local; + let network = match routing { + Routing::Curved => VectorNetwork::from_path(&rel), + _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), + }; let t = Affine::translate((b.x0, b.y0)); let same = n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); @@ -1057,8 +1162,12 @@ fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { } } +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + /// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight or in right angles. +/// facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. pub fn route(doc: &Document, c: &Connector) -> Option> { let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); let centre = |e: &End, b: Option| match e { @@ -1067,25 +1176,36 @@ pub fn route(doc: &Document, c: &Connector) -> Option> { }; let (fb, tb) = (bounds(&c.from), bounds(&c.to)); let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, h0) = attach(&c.from, fb, tc)?; - let (p1, h1) = attach(&c.to, tb, fc)?; - let mut pts = match c.routing { - Routing::Straight => vec![p0, p1], - Routing::Elbow => elbow(p0, h0, p1, h1), + let (p0, s0) = attach(&c.from, fb, tc)?; + let (p1, s1) = attach(&c.to, tb, fc)?; + let pts = match c.routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), + Routing::Curved => curve(p0, s0, p1, s1), }; - pts.dedup(); - // A turn that doesn't turn (the ends in line) is no point of the route. - let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) { - (Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y), - _ => false, - }; - let pts: Vec = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect(); pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) } -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and -/// whether that side faces sideways (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { match e { End::Point { x, y } => Some((Point::new(*x, *y), None)), End::Node { side, .. } => { @@ -1104,14 +1224,55 @@ fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option } s => *s, }; - Some((side.point(b), Some(side.horizontal()))) + Some((side.point(b), Some(side))) } } } -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals -/// (`h`: sideways; a free end follows the other). -fn elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); let h1 = h1.unwrap_or(h0); match (h0, h1) { @@ -1229,16 +1390,42 @@ impl Session { if to.node().is_some() && to.node() == from.node() { return Ok(()); } - let json_end = |e: End| match e { - End::Node { node, .. } => json!(node), - End::Point { x, y } => json!({"x": x, "y": y}), - }; let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?; + self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; self.board_placed(); Ok(()) } + /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end + /// to the pointer, and the object it would attach to outlined. + pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { + self.tool.gesture = Gesture::Reattach { id, from, start }; + let Ok(doc) = self.doc() else { return }; + let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); + let other = other_end(doc, id, from).and_then(|e| e.node()); + self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); + self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). + /// A click, or a drop on the object the other end is on, changes nothing. + pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let target = self.end_at(ev); + let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); + if target.node().is_some() && target.node() == other { + return Ok(()); + } + self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + Ok(()) + } + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the /// gesture ends, which is one undo step). pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs new file mode 100644 index 00000000..cbb2070e --- /dev/null +++ b/crates/engine/src/board_connector_tests.rs @@ -0,0 +1,179 @@ +//! Board connectors, continued: moving an end onto another object or off into the canvas (the +//! command and a drag of the selected connector's end), curved connectors, and elbows that go +//! around the objects they join. + +use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side}; +use figcraft_geom::{Point, Rect}; +use serde_json::json; + +use crate::Session; +use crate::board::route; +use crate::board_tests::{board, connector, drawn, id, near, node}; +use crate::tools::{PointerEvent, PointerKind, handle_at}; + +/// A board with three 100 × 100 shapes in a row 300 apart, and an elbow connector from the +/// first to the second: (a, b, c, connector). +fn three() -> (Session, [figcraft_doc::NodeId; 4]) { + let mut s = board(); + let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + let (a, b, c) = (shape(0.0), shape(300.0), shape(600.0)); + let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id"); + (s, [a, b, c, k]) +} + +#[test] +fn reattach_moves_one_end_and_undoes() { + let (mut s, [a, b, c, k]) = three(); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap(); + assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top }); + assert_eq!(connector(&s, k).from.node(), Some(a)); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step"); + // Off into the canvas: the end stays there, free. The selection is the default connector. + s.execute("select.set", &json!({"ids": [k]})).unwrap(); + s.execute("board.reattach", &json!({"end": "from", "target": {"x": -200, "y": 400}})).unwrap(); + assert_eq!(connector(&s, k).from, End::Point { x: -200.0, y: 400.0 }); + assert!(near(drawn(&s, k)[0], Point::new(-200.0, 400.0))); + // A label stands for its connector. + let label = id(&s.execute("board.label", &json!({"id": k, "text": "next"})).unwrap(), "label"); + s.execute("board.reattach", &json!({"id": label, "end": "from", "target": c})).unwrap(); + assert_eq!(connector(&s, k).from.node(), Some(c)); +} + +#[test] +fn reattach_refuses_what_it_cant_join() { + let (mut s, [a, b, _, k]) = three(); + let refused = |s: &mut Session, p| s.execute("board.reattach", &p).is_err(); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": a})), "both ends on one layer"); + assert!(refused(&mut s, json!({"id": k, "end": "middle", "target": b}))); + assert!(refused(&mut s, json!({"id": k, "end": "to"})), "no target"); + assert!(refused(&mut s, json!({"id": a, "end": "to", "target": b})), "not a connector"); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": k})), "a connector isn't an end"); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": {"x": "a", "y": 1}}))); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": b, "side": "inside"}))); + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + assert!(d.execute("board.reattach", &json!({"end": "to", "target": {"x": 0, "y": 0}})).is_err(), "boards only"); + assert_eq!(connector(&s, k).to.node(), Some(b), "nothing changed"); +} + +#[test] +fn dragging_a_selected_connectors_end_reattaches_it() { + let (mut s, [a, b, c, k]) = three(); + s.execute("select.set", &json!({"ids": [k]})).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(handle_at(doc, &[k], Point::new(100.0, 50.0), 1.0), None, "no box handles on a connector"); + let end = *drawn(&s, k).last().unwrap(); + let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0); + // Onto c. + s.pointer(&ev(PointerKind::Down, end)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(500.0, 50.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(650.0, 50.0))).unwrap(); + assert_eq!(s.tool.overlay.drop_target, Some(c), "the object it would attach to shows"); + assert!(s.tool.overlay.rubber.is_some()); + s.pointer(&ev(PointerKind::Up, Point::new(650.0, 50.0))).unwrap(); + assert_eq!(connector(&s, k).to.node(), Some(c)); + assert_eq!(connector(&s, k).from.node(), Some(a)); + assert_eq!((s.tool.overlay.drop_target, s.tool.overlay.rubber), (None, None)); + assert_eq!(s.selection(), vec![k], "still selected"); + // The start into empty canvas: free where it was let go. + let start = drawn(&s, k)[0]; + s.pointer(&ev(PointerKind::Down, start + figcraft_geom::Vec2::new(3.0, 0.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(100.0, 400.0))).unwrap(); + s.pointer(&ev(PointerKind::Up, Point::new(100.0, 400.0))).unwrap(); + assert_eq!(connector(&s, k).from, End::Point { x: 100.0, y: 400.0 }); + // A click on an end, or a drop on the other end's object, changes nothing. + let before = connector(&s, k); + let end = *drawn(&s, k).last().unwrap(); + s.pointer(&ev(PointerKind::Down, end)).unwrap(); + s.pointer(&ev(PointerKind::Up, end)).unwrap(); + assert_eq!(connector(&s, k), before); + s.execute("board.reattach", &json!({"id": k, "end": "from", "target": b})).unwrap(); + let before = connector(&s, k); + s.pointer(&ev(PointerKind::Down, *drawn(&s, k).last().unwrap())).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(350.0, 50.0))).unwrap(); + assert_eq!(s.tool.overlay.drop_target, None, "not onto the other end's object"); + s.pointer(&ev(PointerKind::Up, Point::new(350.0, 50.0))).unwrap(); + assert_eq!(connector(&s, k), before); +} + +#[test] +fn curved_connectors_leave_and_arrive_along_the_sides() { + let (mut s, [a, _, c, k]) = three(); + s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})).unwrap(); + assert_eq!(connector(&s, k).routing, Routing::Curved); + let n = node(&s, k); + let NodeKind::Vector { network } = &n.kind else { panic!("a vector") }; + assert_eq!((network.vertices.len(), network.segments.len()), (2, 1)); + assert!(!network.segments[0].is_straight(), "one curve"); + assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + // Side to side in a line: the curve's control points lie on that line, so its box is flat. + assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height); + // From a's bottom to c's top: out downwards and in from above; the box holds the whole curve. + let k2 = id( + &s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(), + "id", + ); + let pts = route(s.doc().unwrap(), &connector(&s, k2)).unwrap(); + assert_eq!(pts.len(), 4); + assert!(pts[1].y > pts[0].y && pts[2].y < pts[3].y, "{pts:?}"); + let b = s.doc().unwrap().world_bounds(k2).unwrap(); + assert!(b.y0 <= 0.0 + 1e-9 && b.y1 >= 100.0 - 1e-9, "{b:?}"); + // The label sits on the curve's middle (by length): here, by symmetry, the middle of the box. + let label = crate::board::label_of(s.doc().unwrap(), k2).unwrap(); + let lc = s.doc().unwrap().world_bounds(label).unwrap().center(); + assert!((lc - b.center()).hypot() < 0.5, "{lc:?} vs {:?}", b.center()); + assert!(s.execute("board.setStyle", &json!({"ids": [k], "style": "wavy"})).is_err()); + s.execute("tool.connector", &json!({"style": "curved"})).unwrap(); + assert_eq!(s.tool.board.routing, Routing::Curved); +} + +/// Whether `pts` (a right-angled route) runs through the inside of `r`. +fn through(pts: &[Point], r: Rect) -> bool { + pts.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + a.x.min(b.x) < r.x1 - 0.5 && a.x.max(b.x) > r.x0 + 0.5 && a.y.min(b.y) < r.y1 - 0.5 && a.y.max(b.y) > r.y0 + 0.5 + }) +} + +#[test] +fn elbows_go_around_the_objects_they_join() { + let (mut s, [a, b, _, k]) = three(); + // Out of a's left and b's right: the short way runs through both, so it goes over them. + s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "left", "toSide": "right", "style": "elbow"})).unwrap(); + let k2 = s.selection()[0]; + let pts = drawn(&s, k2); + let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0)); + assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); + assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0))); + assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0))); + assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}"); + assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}"); + assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only"); + // Facing each other, the short way stays. + assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + // An end at the bottom of b leaving towards a above it: around b's side and up. + s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap(); + let pts = drawn(&s, k2); + assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); + assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0))); +} + +#[test] +fn reattach_survives_junk() { + let (mut s, [_, _, _, k]) = three(); + for p in [ + json!({"id": k, "end": "to", "target": {"x": 1e308, "y": -1e308}}), + json!({"id": k, "end": "from", "target": {"x": f64::MAX, "y": 0}, "side": 5}), + json!({"id": [k], "end": ["to"], "target": [1]}), + json!({"ids": "x", "end": null, "target": null}), + json!({"id": k, "end": "to", "target": 99_999}), + ] { + let _ = s.execute("board.reattach", &p); + let _ = s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})); + let _ = s.execute("board.reattach", &p); + } + assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_)))); + assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite())); +} diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index 5b8cbbf9..56b24b84 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -277,6 +277,8 @@ fn board_commands_survive_junk() { json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}), json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}), json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}), + json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}), + json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}), ]; let ids: Vec<&str> = crate::cmd::all_commands() .iter() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9298022..21b56bad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1614,6 +1614,8 @@ mod batch_step_ref_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod board_connector_tests; +#[cfg(test)] mod board_tests; #[cfg(test)] mod board_tools_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 4b7b2abf..935c9048 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -446,6 +446,13 @@ pub(crate) enum Gesture { from: figcraft_doc::End, start: Point, }, + /// Dragging the selected connector `id`'s end (`from`: its start, pressed at `start`) to + /// attach it elsewhere. + Reattach { + id: NodeId, + from: bool, + start: Point, + }, /// The eraser: the strokes it took so far. Erase { gone: Vec, @@ -689,6 +696,10 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option< if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { return None; } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } let corners = selection_box(doc, sel)?; // Lines (zero height) only get end handles. let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); @@ -1144,6 +1155,9 @@ impl Session { } } } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } self.tool.overlay.measure_box = measure_box; self.tool.overlay.cursor = cursor; if tool == ToolId::Pen { @@ -1204,8 +1218,13 @@ impl Session { } textedit::end(self)?; } - // Handles first: a selected ellipse's arc handles (drawn over the box), then the box's. + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } if self.arc_handles_shown() && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) && let Some(arc) = crate::arc_handles::arc_of(&doc, id) @@ -1554,6 +1573,11 @@ impl Session { self.tool.gesture = Gesture::Erase { gone: gone.clone() }; return self.erase_at(ev); } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } Gesture::PenDrag { anchor } => { // A click's wobble pulls out no handle. let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; @@ -1941,6 +1965,7 @@ impl Session { } Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, diff --git a/crates/render/src/dots.rs b/crates/render/src/dots.rs new file mode 100644 index 00000000..b2d2900c --- /dev/null +++ b/crates/render/src/dots.rs @@ -0,0 +1,82 @@ +//! A dot grid on the page, under its layers (the board canvas): square dots on the device-pixel +//! grid, blended into the image right after the page colour so the layers cover them. Each dot's +//! pixels are computed from its page position alone, so bands and partial renders line up. Nothing +//! is allocated per dot. + +use figcraft_color::Color; +use tiny_skia::Pixmap; + +use crate::View; + +/// Dots every `step` page units from the page origin, `size` page units square (from one to 64 +/// device pixels): those on every second line both ways in `color`, the others (the ones a grid +/// twice as coarse leaves out) in `faded`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dots { + pub step: f64, + pub size: f64, + pub color: Color, + pub faded: Color, +} + +/// The most dots one render draws: a step far too small for the view draws none. +const MAX_DOTS: f64 = 4_000_000.0; + +/// `c` as premultiplied 8-bit RGBA. +fn premultiplied(c: Color) -> [u32; 4] { + let [r, g, b, a] = c.to_rgba8().map(u32::from); + [r * a / 255, g * a / 255, b * a / 255, a] +} + +/// Draw `d` into `pix`, the render of `view`. +pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) { + let s = view.scale; + let ok = |v: f64| v.is_finite() && v > 0.0; + if !(ok(d.step) && ok(d.size) && ok(s) && view.origin.0.is_finite() && view.origin.1.is_finite()) { + return; + } + let (w, h) = (i64::from(pix.width()), i64::from(pix.height())); + let (x0, y0) = view.origin; + let (i0, i1) = ((x0 / d.step).floor() - 1.0, ((x0 + w as f64 / s) / d.step).ceil() + 1.0); + let (j0, j1) = ((y0 / d.step).floor() - 1.0, ((y0 + h as f64 / s) / d.step).ceil() + 1.0); + let count = (i1 - i0 + 1.0) * (j1 - j0 + 1.0); + if !(count.is_finite() && count <= MAX_DOTS) { + return; + } + // Each dot on the device pixel nearest its point, from the view's top-left in device pixels. + let (ox, oy) = (x0 * s, y0 * s); + let side = ((d.size * s).round() as i64).clamp(1, 64); + let lead = side / 2; + let (strong, faded) = (premultiplied(d.color), premultiplied(d.faded)); + let row = w as usize * 4; + let data = pix.data_mut(); + for j in j0 as i64..=j1 as i64 { + let top = ((j as f64 * d.step * s - oy).round() as i64).saturating_sub(lead); + let (ya, yb) = (top.max(0), top.saturating_add(side).min(h)); + if ya >= yb { + continue; + } + for i in i0 as i64..=i1 as i64 { + let left = ((i as f64 * d.step * s - ox).round() as i64).saturating_sub(lead); + let (xa, xb) = (left.max(0), left.saturating_add(side).min(w)); + if xa >= xb { + continue; + } + let [r, g, b, a] = if i % 2 == 0 && j % 2 == 0 { strong } else { faded }; + if a == 0 { + continue; + } + for y in ya..yb { + let start = y as usize * row; + let Some(line) = data.get_mut(start + xa as usize * 4..start + xb as usize * 4) else { continue }; + for px in line.chunks_exact_mut(4) { + // Source over, premultiplied. + let keep = 255 - a; + for (dst, src) in px.iter_mut().zip([r, g, b, a]) { + *dst = (src + u32::from(*dst) * keep / 255).min(255) as u8; + } + } + } + } + } +} diff --git a/crates/render/src/dots_tests.rs b/crates/render/src/dots_tests.rs new file mode 100644 index 00000000..3adcbeab --- /dev/null +++ b/crates/render/src/dots_tests.rs @@ -0,0 +1,88 @@ +//! The board canvas's dot grid: on the page colour, under the layers, the same pixels whichever +//! part of the view a render covers (bands, retained patches), never in exports, and nothing +//! drawn (or panicking) for spacings and sizes it can't draw. + +use std::sync::Arc; + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeKind}; + +use crate::{Dots, Options, Retained, View, render_view, render_view_single}; + +fn dots() -> Dots { + Dots { step: 24.0, size: 2.0, color: Color::new(0.0, 0.0, 0.0, 0.5), faded: Color::new(0.0, 0.0, 0.0, 0.25) } +} + +fn page_with_square() -> (Document, figcraft_doc::NodeId) { + let mut d = Document::new("dots"); + let page = d.first_page().unwrap(); + let r = d.create(page, NodeKind::Rectangle, "Square", 40.0, 40.0, 60.0, 60.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("2D6CDF").unwrap())]; + (d, page) +} + +fn rgba(p: &crate::Pixmap, x: u32, y: u32) -> [u8; 4] { + let c = p.pixel(x, y).unwrap(); + [c.red(), c.green(), c.blue(), c.alpha()] +} + +#[test] +fn dots_sit_on_the_page_under_the_layers() { + let (d, page) = page_with_square(); + let view = View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }; + let pix = render_view_single(&d, page, &view, &Options { dots: Some(dots()), ..Default::default() }).unwrap(); + let plain = render_view_single(&d, page, &view, &Options::default()).unwrap(); + // A strong dot at (0, 0) (every second line both ways), a fainter one at (24, 0). + let (strong, faint, empty) = (rgba(&pix, 0, 0), rgba(&pix, 24, 0), rgba(&pix, 12, 0)); + assert_eq!(empty, rgba(&plain, 12, 0), "nothing between the dots"); + assert!(strong[0] < faint[0] && faint[0] < empty[0], "{strong:?} {faint:?} {empty:?}"); + assert_eq!(rgba(&pix, 48, 48), rgba(&plain, 48, 48), "the square covers the dot at (48, 48)"); + // Exports and thumbnails (no dots asked for) are unchanged. + assert_ne!(pix.data(), plain.data()); +} + +#[test] +fn dots_line_up_across_bands_and_patches() { + let (d, page) = page_with_square(); + let opts = Options { dots: Some(dots()), ..Default::default() }; + // On a whole-pixel origin, as the canvas renders. + let full = View { origin: (-212.0, -140.0), scale: 1.5, width: 900, height: 700 }; + let a = render_view_single(&d, page, &full, &opts).unwrap(); + // Banded in parallel (desktop) or in one piece: the same pixels. + let b = render_view(&d, page, &full, &opts).unwrap(); + assert_eq!(a.data(), b.data()); + // A view on the same pixel grid, a whole number of pixels further on, shows the same dots. + let shifted = View { origin: (full.origin.0 + 101.0 / 1.5, full.origin.1 + 37.0 / 1.5), ..full.clone() }; + let c = render_view_single(&d, page, &shifted, &opts).unwrap(); + for (x, y) in [(0, 0), (150, 90), (400, 300), (700, 500)] { + assert_eq!(rgba(&c, x, y), rgba(&a, x + 101, y + 37), "at {x}, {y}"); + } + // An edit redraws only around the layer, and the patched image matches a fresh render. + let mut retained = Retained::default(); + let doc = Arc::new(d.clone()); + retained.render(&doc, page, &full, &opts).unwrap(); + let mut e = d; + let sq = e.get(page).unwrap().children[0]; + e.get_mut(sq).unwrap().fills = vec![Paint::solid(Color::from_hex("E8505B").unwrap())]; + let e = Arc::new(e); + let (snapped, patched, _) = retained.render(&e, page, &full, &opts).unwrap(); + let fresh = render_view_single(&e, page, &snapped, &opts).unwrap(); + assert_eq!(patched.data(), fresh.data()); +} + +#[test] +fn dots_it_cant_draw_draw_nothing() { + let (d, page) = page_with_square(); + let view = View { origin: (-1e12, 1e12), scale: 1.0, width: 64, height: 64 }; + let plain = render_view_single(&d, page, &view, &Options::default()).unwrap(); + for step in [0.0, -24.0, f64::NAN, f64::INFINITY, 1e-9] { + let opts = Options { dots: Some(Dots { step, ..dots() }), ..Default::default() }; + assert_eq!(render_view_single(&d, page, &view, &opts).unwrap().data(), plain.data(), "step {step}"); + } + for size in [f64::NAN, 1e308, -1.0] { + let opts = Options { dots: Some(Dots { size, ..dots() }), ..Default::default() }; + let _ = render_view_single(&d, page, &view, &opts); + } + let far = View { origin: (f64::MAX / 2.0, -f64::MAX / 2.0), scale: 1e10, width: 16, height: 16 }; + let _ = render_view_single(&d, page, &far, &Options { dots: Some(dots()), ..Default::default() }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2a911076..0ad87d94 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ pub mod backdrop; pub mod blur; pub mod boolean; +pub mod dots; pub mod draw; mod export_depth; pub mod fx; @@ -21,6 +22,7 @@ pub mod pattern; pub mod retained; pub mod shadow; +pub use dots::Dots; pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path}; pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth}; pub use retained::{Retained, Work}; @@ -79,6 +81,9 @@ pub struct Options { /// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no /// effect in outline mode. pub lod: bool, + /// A dot grid over the background, under the layers (a board's canvas; drawn only where the + /// background is). + pub dots: Option, } fn new_pixmap(w: u32, h: u32) -> Option { @@ -168,6 +173,9 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti }); if let Some(bg) = bg { pix.fill(paint::ts_color(bg)); + if let Some(d) = &opts.dots { + dots::draw(&mut pix, view, d); + } } let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE; let bounds = draw::Bounds::default(); @@ -440,6 +448,8 @@ mod boolean_cache_tests; #[cfg(test)] mod bounds_memo_tests; #[cfg(test)] +mod dots_tests; +#[cfg(test)] mod effect_order_tests; #[cfg(test)] mod effects_behaviour_tests; diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 9776b028..dcb76ffc 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -48,6 +48,7 @@ struct Frame { skip: Vec, solo: Option, background: Option, + dots: Option, scale: u64, /// The view's top-left in device pixels (page origin × scale). px: (i64, i64), @@ -159,6 +160,7 @@ impl Retained { && last.skip == opts.skip && last.solo == opts.solo && last.background == opts.background + && last.dots == opts.dots && last.scale == snapped.scale.to_bits() && last.pix.width() == snapped.width && last.pix.height() == snapped.height @@ -203,6 +205,7 @@ impl Retained { skip: opts.skip.clone(), solo: opts.solo, background: opts.background, + dots: opts.dots, scale: view.scale.to_bits(), px, fonts, diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 3e2ce652..6223c501 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -445,6 +445,7 @@ status = "draft — needs native review" "Effect settings" = "ইফেক্ট সেটিংস" "Effect styles" = "ইফেক্ট স্টাইল" "Effects" = "ইফেক্টসমূহ" +"Curved" = "বাঁকা" "Elbow" = "কোণযুক্ত" "Ellipse" = "উপবৃত্ত" "Ellipses" = "উপবৃত্তসমূহ" @@ -1771,6 +1772,7 @@ status = "draft — needs native review" "board.ink" = "মার্কারের টান" "board.label" = "সংযোগকারীর লেবেল" "board.nextSticky" = "পরের স্টিকি নোট" +"board.reattach" = "সংযোগকারী পুনরায় যুক্ত করুন" "board.setColor" = "বোর্ডের রং" "board.setStyle" = "বোর্ডের শৈলী" "board.shape" = "লেখাসহ আকৃতি" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 6544b7b8..899b3fcf 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -432,6 +432,7 @@ status = "draft — needs native review" "Effect settings" = "Nastavení efektu" "Effect styles" = "Styly efektů" "Effects" = "Efekty" +"Curved" = "Zakřivená" "Elbow" = "Lomená" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" @@ -1758,6 +1759,7 @@ status = "draft — needs native review" "board.ink" = "Tah fixem" "board.label" = "Popisek spojnice" "board.nextSticky" = "Další lepicí poznámka" +"board.reattach" = "Připojit spojnici jinam" "board.setColor" = "Barva na tabuli" "board.setStyle" = "Styl na tabuli" "board.shape" = "Tvar s textem" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b8cf80ef..b8527ed4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -427,6 +427,7 @@ status = "draft — needs native review" "Effect settings" = "Effekteinstellungen" "Effect styles" = "Effektstile" "Effects" = "Effekte" +"Curved" = "Gebogen" "Elbow" = "Gewinkelt" "Ellipse" = "Ellipse" "Ellipses" = "Ellipsen" @@ -1714,6 +1715,7 @@ status = "draft — needs native review" "board.ink" = "Markerstrich" "board.label" = "Verbinderbeschriftung" "board.nextSticky" = "Nächste Haftnotiz" +"board.reattach" = "Verbinder neu verbinden" "board.setColor" = "Whiteboard-Farbe" "board.setStyle" = "Whiteboard-Stil" "board.shape" = "Form mit Text" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 2f302eff..c1fb2333 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -448,6 +448,7 @@ name = "English" "Effect settings" = "Effect settings" "Effect styles" = "Effect styles" "Effects" = "Effects" +"Curved" = "Curved" "Elbow" = "Elbow" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3f4263da..9da68641 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -449,6 +449,7 @@ status = "draft — needs native review" "Effect settings" = "Ajustes del efecto" "Effect styles" = "Estilos de efecto" "Effects" = "Efectos" +"Curved" = "Curvo" "Elbow" = "En ángulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" @@ -1821,6 +1822,7 @@ status = "draft — needs native review" "board.ink" = "Trazo de rotulador" "board.label" = "Etiqueta del conector" "board.nextSticky" = "Siguiente nota adhesiva" +"board.reattach" = "Volver a unir el conector" "board.setColor" = "Color en la pizarra" "board.setStyle" = "Estilo en la pizarra" "board.shape" = "Forma con texto" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 8b1b8743..91beb2e8 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -447,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Tehosteen asetukset" "Effect styles" = "Tehostetyylit" "Effects" = "Tehosteet" +"Curved" = "Kaareva" "Elbow" = "Kulmikas" "Ellipse" = "Ellipsi" "Ellipses" = "Ellipsit" @@ -1800,6 +1801,7 @@ status = "draft — needs native review" "board.ink" = "Tussiviiva" "board.label" = "Yhdistimen nimiö" "board.nextSticky" = "Seuraava muistilappu" +"board.reattach" = "Liitä yhdistin uudelleen" "board.setColor" = "Taulun väri" "board.setStyle" = "Taulun tyyli" "board.shape" = "Muoto ja teksti" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5616dca1..3da1067d 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -446,6 +446,7 @@ status = "draft — needs native review" "Effect settings" = "Réglages de l’effet" "Effect styles" = "Styles d’effet" "Effects" = "Effets" +"Curved" = "Courbe" "Elbow" = "Coudé" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" @@ -1801,6 +1802,7 @@ status = "draft — needs native review" "board.ink" = "Trait de marqueur" "board.label" = "Libellé du connecteur" "board.nextSticky" = "Note autocollante suivante" +"board.reattach" = "Rattacher le connecteur" "board.setColor" = "Couleur du tableau" "board.setStyle" = "Style du tableau" "board.shape" = "Forme avec texte" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index bf5fe82e..d588fa96 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -436,6 +436,7 @@ status = "draft — needs native review" "Effect settings" = "Pengaturan efek" "Effect styles" = "Gaya efek" "Effects" = "Efek" +"Curved" = "Lengkung" "Elbow" = "Siku" "Ellipse" = "Elips" "Ellipses" = "Elips" @@ -1762,6 +1763,7 @@ status = "draft — needs native review" "board.ink" = "Goresan spidol" "board.label" = "Label konektor" "board.nextSticky" = "Catatan tempel berikutnya" +"board.reattach" = "Pasang ulang konektor" "board.setColor" = "Warna papan" "board.setStyle" = "Gaya papan" "board.shape" = "Bentuk dengan teks" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index a6ffa01f..2a19c00d 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -441,6 +441,7 @@ status = "draft — needs native review" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" +"Curved" = "曲線" "Elbow" = "直角" "Ellipse" = "楕円" "Ellipses" = "楕円" @@ -1781,6 +1782,7 @@ status = "draft — needs native review" "board.ink" = "マーカーの線" "board.label" = "コネクタのラベル" "board.nextSticky" = "次の付箋" +"board.reattach" = "コネクタを付け替え" "board.setColor" = "ボードの色" "board.setStyle" = "ボードのスタイル" "board.shape" = "テキスト付きシェイプ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index ffbc8444..83a15ac3 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -447,6 +447,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Effect settings" = "ეფექტის პარამეტრები" "Effect styles" = "ეფექტის სტილები" "Effects" = "ეფექტები" +"Curved" = "მრუდი" "Elbow" = "კუთხოვანი" "Ellipse" = "ელიფსი" "Ellipses" = "ელიფსები" @@ -1797,6 +1798,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "board.ink" = "მარკერის შტრიხი" "board.label" = "შემაერთებლის წარწერა" "board.nextSticky" = "შემდეგი სტიკერი" +"board.reattach" = "შემაერთებლის ხელახლა მიმაგრება" "board.setColor" = "დაფის ფერი" "board.setStyle" = "დაფის სტილი" "board.shape" = "ფიგურა ტექსტით" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 2192e29b..d83aea66 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -423,6 +423,7 @@ status = "draft — needs native review" "Effect settings" = "효과 설정" "Effect styles" = "효과 스타일" "Effects" = "효과" +"Curved" = "곡선" "Elbow" = "꺾은선" "Ellipse" = "타원" "Ellipses" = "타원" @@ -1705,6 +1706,7 @@ status = "draft — needs native review" "board.ink" = "마커 획" "board.label" = "커넥터 레이블" "board.nextSticky" = "다음 스티커 메모" +"board.reattach" = "커넥터 다시 연결" "board.setColor" = "보드 색상" "board.setStyle" = "보드 스타일" "board.shape" = "텍스트가 있는 도형" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 2b301b39..9218262b 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -447,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Effectinstellingen" "Effect styles" = "Effectstijlen" "Effects" = "Effecten" +"Curved" = "Gebogen" "Elbow" = "Haaks" "Ellipse" = "Ellips" "Ellipses" = "Ellipsen" @@ -1799,6 +1800,7 @@ status = "draft — needs native review" "board.ink" = "Stiftlijn" "board.label" = "Label van verbinding" "board.nextSticky" = "Volgend plakbriefje" +"board.reattach" = "Verbinding opnieuw koppelen" "board.setColor" = "Whiteboardkleur" "board.setStyle" = "Whiteboardstijl" "board.shape" = "Vorm met tekst" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 51c59f9e..1dc9513e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -444,6 +444,7 @@ status = "draft — needs native review" "Effect settings" = "Ustawienia efektów" "Effect styles" = "Style efektów" "Effects" = "Efekty" +"Curved" = "Zakrzywiony" "Elbow" = "Łamany" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" @@ -1795,6 +1796,7 @@ status = "draft — needs native review" "board.ink" = "Pociągnięcie markera" "board.label" = "Etykieta łącznika" "board.nextSticky" = "Następna karteczka" +"board.reattach" = "Podłącz łącznik ponownie" "board.setColor" = "Kolor na tablicy" "board.setStyle" = "Styl na tablicy" "board.shape" = "Kształt z tekstem" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index ed841775..8aa1ed5d 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -422,6 +422,7 @@ status = "draft — needs native review" "Effect settings" = "Configurações do efeito" "Effect styles" = "Estilos de efeito" "Effects" = "Efeitos" +"Curved" = "Curvo" "Elbow" = "Em ângulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" @@ -1818,6 +1819,7 @@ status = "draft — needs native review" "board.ink" = "Traço de marcador" "board.label" = "Rótulo do conector" "board.nextSticky" = "Próxima nota adesiva" +"board.reattach" = "Reconectar conector" "board.setColor" = "Cor no quadro" "board.setStyle" = "Estilo no quadro" "board.shape" = "Forma com texto" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 38cfaa4e..f8062989 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -446,6 +446,7 @@ name = "Русский" "Effect settings" = "Настройки эффекта" "Effect styles" = "Стили эффектов" "Effects" = "Эффекты" +"Curved" = "Кривая" "Elbow" = "Ломаная" "Ellipse" = "Эллипс" "Ellipses" = "Эллипсы" @@ -1812,6 +1813,7 @@ name = "Русский" "board.ink" = "Штрих маркера" "board.label" = "Подпись соединителя" "board.nextSticky" = "Следующий стикер" +"board.reattach" = "Переподключить соединитель" "board.setColor" = "Цвет на доске" "board.setStyle" = "Стиль на доске" "board.shape" = "Фигура с текстом" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a5f4d4ba..8f5e4b0c 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -441,6 +441,7 @@ name = "Svenska" "Effect settings" = "Effektinställningar" "Effect styles" = "Effektstilar" "Effects" = "Effekter" +"Curved" = "Böjd" "Elbow" = "Vinklad" "Ellipse" = "Ellips" "Ellipses" = "Ellipser" @@ -1781,6 +1782,7 @@ name = "Svenska" "board.ink" = "Märkpennsdrag" "board.label" = "Kopplingsetikett" "board.nextSticky" = "Nästa lapp" +"board.reattach" = "Koppla om koppling" "board.setColor" = "Whiteboardfärg" "board.setStyle" = "Whiteboardstil" "board.shape" = "Form med text" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index aa3207a5..b3768592 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -436,6 +436,7 @@ name = "Türkçe" "Effect settings" = "Efekt ayarları" "Effect styles" = "Efekt stilleri" "Effects" = "Efektler" +"Curved" = "Eğri" "Elbow" = "Dirsekli" "Ellipse" = "Elips" "Ellipses" = "Elipsler" @@ -1762,6 +1763,7 @@ name = "Türkçe" "board.ink" = "Keçeli kalem çizgisi" "board.label" = "Bağlayıcı etiketi" "board.nextSticky" = "Sonraki yapışkan not" +"board.reattach" = "Bağlayıcıyı yeniden bağla" "board.setColor" = "Pano rengi" "board.setStyle" = "Pano stili" "board.shape" = "Metinli şekil" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 3d56e7a3..3136fef3 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -424,6 +424,7 @@ status = "draft — needs native review" "Effect settings" = "效果设置" "Effect styles" = "效果样式" "Effects" = "效果" +"Curved" = "曲线" "Elbow" = "折线" "Ellipse" = "椭圆" "Ellipses" = "椭圆" @@ -1706,6 +1707,7 @@ status = "draft — needs native review" "board.ink" = "马克笔笔画" "board.label" = "连接线标签" "board.nextSticky" = "下一张便利贴" +"board.reattach" = "重新连接连接线" "board.setColor" = "白板颜色" "board.setStyle" = "白板样式" "board.shape" = "带文字的形状" diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index c312ddb9..d49f38ec 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -142,7 +142,7 @@ impl Has { w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END; } if self.connector.is_some() { - w += 3.0 * (CELL + 2.0) + PART_END; + w += 4.0 * (CELL + 2.0) + PART_END; } w } @@ -254,12 +254,13 @@ fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> divider(ui, t, x, cy) } -/// A connector's routing (straight, elbow) and end arrow at `at`, as they are now (`line`). +/// A connector's routing (straight, elbow, curved) and end arrow at `at`, as they are now (`line`). fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: &Tokens) { let (routing, arrow) = line; let cells = [ (Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})), (Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})), + (Icon::CurvedConnector, tr("Curved"), routing == Routing::Curved, json!({"style": "curved"})), (Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})), ]; let mut x = at.x; @@ -278,6 +279,48 @@ fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { x + 8.0 } +/// The dot grid's spacing on the page at 100%, and the closest its dots come on screen (points): +/// zoomed out, every second, fourth… dot stays, the ones going fading out first. +const DOT_STEP: f64 = 24.0; +const DOT_MIN_GAP: f64 = 14.0; + +/// A board page's dot grid on its background `bg`, for the canvas render to draw under the +/// layers: dark dots on a light page, light ones on a dark page, in the theme's ink, about a point +/// and a half across on screen. `None` on design files. +pub fn dots(app: &FigApp, bg: Color32, t: &Tokens) -> Option { + let ds = app.session.ds().ok()?; + if !figcraft_engine::board::is_board(&ds.doc) { + return None; + } + let zoom = ds.view.zoom; + let (step, fade) = dot_spacing(zoom)?; + let dark_page = 0.2126 * f32::from(bg.r()) + 0.7152 * f32::from(bg.g()) + 0.0722 * f32::from(bg.b()) < 128.0; + let ink = if dark_page == t.dark { t.text } else { t.bg }; + let alpha = if dark_page { 0.22 } else { 0.18 }; + let color = |a: f32| figcraft_color::Color::from_rgba8(ink.r(), ink.g(), ink.b(), (a * 255.0).round() as u8); + let size = (1.5 * zoom.sqrt()).clamp(1.0, 3.0) / zoom; + Some(figcraft_render::Dots { step, size, color: color(alpha), faded: color(alpha * fade) }) +} + +/// The dot grid's spacing on the page at `zoom` (doubled until the dots are far enough apart on +/// screen), and how strongly the dots the next doubling drops still show (0 to 1). +pub(crate) fn dot_spacing(zoom: f64) -> Option<(f64, f32)> { + if !(zoom.is_finite() && zoom > 0.0) { + return None; + } + // Bounded: past about 1000 doublings the step is no longer finite, and there are no dots. + let mut step = DOT_STEP; + for _ in 0..1100 { + if step * zoom >= DOT_MIN_GAP { + break; + } + step *= 2.0; + } + let gap = step * zoom; + // From one to two minimum gaps apart, the in-between dots fade in. + (gap.is_finite() && gap >= DOT_MIN_GAP).then(|| (step, ((gap - DOT_MIN_GAP) / DOT_MIN_GAP).clamp(0.0, 1.0) as f32)) +} + /// The name of the person who placed the stamp under the pointer, beside it. pub fn stamp_author(app: &FigApp, painter: &egui::Painter, pointer: Option) { let (Some(at), Ok(ds)) = (pointer, app.session.ds()) else { return }; diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 4ffb3cf4..a33824e3 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -142,7 +142,7 @@ fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() { u.frame(vec![]); let r = bar(&u).expect("the bar shows for a connector"); let cy = r.center().y; - // After the colours (208): Straight, Elbow, the end's arrow, 30 apart. + // After the colours (208): Straight, Elbow, Curved, the end's arrow, 30 apart. click(&mut u, pos2(r.min.x + 208.0 + 14.0, cy)); let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board { Some(BoardItem::Connector(k)) => k.routing, @@ -151,6 +151,9 @@ fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() { assert_eq!(routing(&u), Routing::Straight); let r = bar(&u).unwrap(); click(&mut u, pos2(r.min.x + 208.0 + 60.0 + 14.0, cy)); + assert_eq!(routing(&u), Routing::Curved); + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 90.0 + 14.0, cy)); assert_eq!(u.app.session.doc().unwrap().node(c).unwrap().stroke.cap_end, StrokeCap::None); // Nothing is offered while a stamp alone is selected: it takes neither colour nor style. let st = id(&u.app.session.execute("board.stamp", &json!({"x": 900, "y": 0, "stamp": "star"})).unwrap()); @@ -210,3 +213,60 @@ fn the_next_sticky_from_the_keyboard_goes_on_typing() { let owner = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0); } + +#[test] +fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() { + let mut u = board(); + let sticky = id(&u.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()); + u.app.session.execute("view.zoom100", &json!({})).unwrap(); + u.frame(vec![]); + let t = crate::theme::Tokens::get(&u.ctx); + let page = figcraft_doc::BOARD_PAGE; + let bg = egui::Color32::from_rgb((page.r * 255.0).round() as u8, (page.g * 255.0).round() as u8, (page.b * 255.0).round() as u8); + let dots = crate::board_bar::dots(&u.app, bg, &t).expect("a board has dots"); + assert!(dots.color.a > 0.0 && dots.color.r < 0.5, "dark dots on the light page: {:?}", dots.color); + // The canvas render draws them on the page, and the layers over them. + let ds = u.app.session.ds().unwrap(); + let view = figcraft_render::View { origin: (-60.0, -60.0), scale: 1.0, width: 400, height: 400 }; + let opts = figcraft_render::Options { dots: Some(dots), ..Default::default() }; + let pix = figcraft_render::render_view(&ds.doc, ds.page, &view, &opts).unwrap(); + let at = |x: f64, y: f64| { + let p = pix.pixel((x + 60.0) as u32, (y + 60.0) as u32).unwrap(); + [p.red(), p.green(), p.blue()] + }; + let plain = [bg.r(), bg.g(), bg.b()]; + let step = dots.step; + assert_ne!(at(-2.0 * step, -2.0 * step), plain, "a dot on the page"); + assert_eq!(at(-2.0 * step + step / 2.0, -2.0 * step), plain, "between the dots"); + let fill = u.app.session.doc().unwrap().node(sticky).unwrap().fills[0].solid_color().unwrap().to_rgba8(); + assert_eq!(at(2.0 * step, 2.0 * step), [fill[0], fill[1], fill[2]], "the sticky covers the dot under it"); + // Zoomed out, the dots space out: every second, fourth… stays, the next to go fading. + use crate::board_bar::dot_spacing; + assert_eq!(dot_spacing(1.0), Some((24.0, ((24.0 - 14.0) / 14.0) as f32))); + assert_eq!(dot_spacing(0.25).map(|d| d.0), Some(96.0)); + assert_eq!(dot_spacing(4.0), Some((24.0, 1.0))); + for z in [1e-300, 1e-9, 0.01, 0.37, 1.0, 64.0, 1e300] { + let (step, fade) = dot_spacing(z).unwrap(); + assert!(step * z >= 14.0 && step * z < 28.0 || step == 24.0, "{z}: {step}"); + assert!((0.0..=1.0).contains(&fade)); + } + assert_eq!(dot_spacing(0.0), None); + assert_eq!(dot_spacing(f64::NAN), None); + // Design files have none. + let d = Ui::new(Session::new()); + assert!(crate::board_bar::dots(&d.app, bg, &t).is_none()); +} + +#[test] +fn a_selected_connector_shows_its_ends_as_handles() { + let mut u = board(); + let a = u.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = u.app.session.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap()["id"].clone(); + u.app.session.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(); + let shapes = flat(&u.frame_output(vec![]).shapes); + let ends = [u.app.to_screen(200.0, 100.0), u.app.to_screen(400.0, 100.0)]; + for end in ends { + let ring = shapes.iter().any(|s| matches!(s, egui::Shape::Circle(c) if (c.center - end).length() < 0.5 && c.radius == 5.0)); + assert!(ring, "a handle at {end:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b13f5a8..56623f54 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -129,6 +129,7 @@ struct JobKey { /// Text lays out anew when fonts arrive (system fonts load in the background). fonts: u64, proof: bool, + dots: Option, } impl FigApp { @@ -191,7 +192,19 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { app.sync_preedit(); // Retained rendering snaps the view outward and adds one pixel on each axis. let max_texture_side = (ctx.input(|i| i.max_texture_side).min(16_384) as u32).saturating_sub(1).max(1); - request_render(app, worker, ppp, max_texture_side); + let t = Tokens::get(&ctx); + // Page background (also behind a stale/partial texture). + let bg = app + .session + .ds() + .ok() + .and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) { + Some(NodeKind::Page { background, .. }) => Some(doc_c32(&ctx, *background)), + _ => None, + }) + .unwrap_or(t.canvas); + let dots = crate::board_bar::dots(app, bg, &t); + request_render(app, worker, ppp, max_texture_side, dots); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); @@ -223,17 +236,6 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { app.canvas.tex_coarse = done.coarse; } let painter = ui.painter_at(rect); - let t = Tokens::get(&ctx); - // Page background (also behind a stale/partial texture). - let bg = app - .session - .ds() - .ok() - .and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) { - Some(NodeKind::Page { background, .. }) => Some(doc_c32(&ctx, *background)), - _ => None, - }) - .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); crate::theme::set_canvas_page(&ctx, bg); if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { @@ -348,7 +350,7 @@ fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32) { +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32, dots: Option) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -387,6 +389,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_s solo: ds.focused(), fonts: figcraft_text::layout_generation(), proof, + dots, }; if let Some(previous) = &app.canvas.submitted { let previous_origin = (previous.origin.0 as f64 / 1000.0, previous.origin.1 as f64 / 1000.0); @@ -409,6 +412,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_s skip: vec![], solo: key.solo, proof, + dots, uid: ds.uid, zoom_preview: ds.view.pixel_preview_scale().is_none(), at: web_time::Instant::now(), @@ -1037,6 +1041,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && let Some((_, w, h)) = selection_frame(&doc, &sel) { let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); + // A selected connector's handles are its two ends (drag one onto another object). + let connector = figcraft_engine::board::selected_connector_ends(&doc, &sel).map(|(_, ends)| ends); let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; let scale = app.session.tool.overlay.scale; if let Some(hint) = scale { @@ -1049,16 +1055,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev // Mode: no handles either. - let handles = if measuring || app.session.tool.dev_mode || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { - 0 - } else if is_line { - 2 - } else if tiny { - 0 - } else { - 4 - }; - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label)); + let handles = + if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + 0 + } else if is_line { + 2 + } else if tiny { + 0 + } else { + 4 + }; + size_label = transform_box( + app, + painter, + b.map(|p| app.to_screen(p.x, p.y)), + col, + sel.len() > 1, + handles, + (!measuring && connector.is_none()).then_some(label), + ); + for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) { + let c = app.to_screen(end.x, end.y); + painter.circle_filled(c, 5.0, Color32::WHITE); + painter.circle_stroke(c, 5.0, stroke(col, 1.5)); + } if !measuring { arc_overlay(app, painter, col); shape_overlay(app, painter, col); @@ -1174,6 +1194,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } } } + } else if let Some([x0, y0, x1, y1]) = ov.rubber { + // A connector being drawn, or one of its ends dragged: from where it starts to the pointer. + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5)); } // Eraser brush (vector edit mode). if let Some([x, y, r]) = ov.brush { diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 4cd32d56..dc3f7e94 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -63,6 +63,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: skip: vec![], solo: None, proof: false, + dots: None, uid: 0, zoom_preview: false, at: web_time::Instant::now(), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c7b10388..f2b3b631 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -202,6 +202,8 @@ icons! { Marker, Highlighter, Connector, + /// A curved connector: the connector's two boxes joined by a quarter bend instead of a corner. + CurvedConnector, Shapes, Stamp, } @@ -1164,6 +1166,12 @@ fn draw(p: &P, icon: Icon) { p.poly(&[(6.5, 4.0), (12.0, 4.0), (12.0, 9.0)], false); p.head((12.0, 9.0), FRAC_PI_2, 2.4); } + CurvedConnector => { + p.rect(1.5, 1.5, 6.5, 6.5, 1.0); + p.rect(9.5, 9.5, 14.5, 14.5, 1.0); + p.arc(6.5, 9.0, 5.5, -FRAC_PI_2, 0.0); + p.head((12.0, 9.0), FRAC_PI_2, 2.4); + } Shapes => { p.rect(1.5, 5.5, 9.5, 13.5, 1.0); p.circle(10.5, 5.5, 4.0); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 192073b3..dfda0e55 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -235,6 +235,7 @@ fn a_finished_background_render_wakes_the_ui() { skip: vec![], solo: None, proof: false, + dots: None, uid: 0, zoom_preview: false, at: web_time::Instant::now(), diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index e2fb9a20..5144ac39 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -37,6 +37,8 @@ pub struct Job { pub solo: Option, /// Soft-proof sRGB: stripe the colours sRGB can't show. pub proof: bool, + /// A board's dot grid under the layers. + pub dots: Option, /// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown. pub uid: u64, /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels @@ -95,6 +97,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< skip: job.skip.clone(), solo: job.solo, lod: true, + dots: job.dots, ..Default::default() }; let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index 0fcd9a29..5d720b7a 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -72,6 +72,7 @@ fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { skip: vec![], solo: None, proof: false, + dots: None, uid, zoom_preview: true, at: web_time::Instant::now(), diff --git a/docs/boards-parity.md b/docs/boards-parity.md index b6928dd7..ca358076 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,12 +1,13 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** major (created: board files, the first slice of the whiteboard workflow) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) -**Breadth ~35%, ready ~20% (estimated, erring low).** A board is a FigCraft file of its own kind -(`file.newBoard`) with sticky notes, shapes with text, connectors that follow what they join, -marker, highlighter and eraser, stamps, text and sections, edited together in the existing live -sessions. The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the -rest of the tail are missing. Remaining: ~30–60 h. Tracked as area 22 of +**Breadth ~37%, ready ~21% (estimated, erring low).** A board is a FigCraft file of its own kind +(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that +follow what they join (straight, elbow or curved, their ends dragged onto other objects), marker, +highlighter and eraser, stamps, text and sections, edited together in the existing live sessions. +The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the rest of the +tail are missing. Remaining: ~28–56 h. Tracked as area 22 of [`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the design product). The work item is gap 24 in [`gaps.md`](gaps.md). @@ -26,14 +27,15 @@ all end with the same geometry. |---|---|---| | Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) | | Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button | -| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow and its arrow; the Design panel opens with its button or View ▸ Design panel | +| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel | | Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | | Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it | | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight or elbow, arrowheads (the stroke's caps), a label in the middle (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | -| Curved connectors, connector waypoints, dragging an end to re-attach | missing | ends are attached when drawn or through `board.connector` | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | +| Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | +| Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | | Quick-create handles (drag out a connected copy) | missing | | | Marker and highlighter | done | the Pencil's stroke fitting with board presets: marker opaque, highlighter wide, see-through and multiplying | | Eraser | done | drag over marker and highlighter strokes to remove them, one undo step | @@ -51,11 +53,12 @@ all end with the same geometry. | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | -| Dot grid on the page | missing | the canvas grid is drawn over the render; a dot grid needs a render pass under the layers | +| Dot grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it; 24 units apart, doubling when zoomed out until the dots are at least 14 points apart, the dots the next doubling drops fading out first; dark dots on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per dot; never in exports | | `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/gaps.md b/docs/gaps.md index 531a230a..2f9d5ea3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: the boards dot grid, connector ends dragged to re-attach, curved connectors; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **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 @@ -37,7 +37,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | -| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 30–60 | [boards-parity](boards-parity.md) | +| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 28–56 | [boards-parity](boards-parity.md) | ## Entries @@ -456,18 +456,22 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-24, M12):** board files (`file.newBoard`) with sticky notes, shapes with text, connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text and sections; a board toolbar and a contextual bar; live sessions sync it all. +- **Landed (2026-10-24, M12):** a dot grid on board pages, under the layers; a selected + connector's ends drag onto another object or empty canvas (`board.reattach`); curved connectors; + elbows that go around the objects they join. - **Missing:** timer and music, voting sessions, board widgets, templates, tables, mind maps, washi - tape, code blocks, audio and huddles, quick-create handles, curved connectors and waypoints, - stamps sticking to the objects they land on, a dot grid, `.jam` files + tape, code blocks, audio and huddles, quick-create handles, connector waypoints placed by hand, + stamps sticking to the objects they land on, `.jam` files ([`boards-parity.md`](boards-parity.md)). - **User impact:** boards cover a brainstorm or a flow sketch; facilitated workshops need the timer, voting and templates. -- **Estimate:** 30–60 h. +- **Estimate:** 28–56 h. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index d73cf78f..82fa39c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -348,20 +348,25 @@ one undo step and returns the ids it made: (`Shift+Cmd/Ctrl+Enter` in the app does both). - `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`. -- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow, startCap?, endCap?, label?}` +- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}` → `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the - end). The connector re-routes itself whenever what it joins moves or resizes, also when someone - else in a live session moves it; a deleted end stays where it was as a point. `board.label {id, - text}` sets its label (empty removes it). + end). An elbow goes around the objects it joins when the short way would cross them; a curved + one leaves and arrives along the sides. The connector re-routes itself whenever what it joins + moves or resizes, also when someone else in a live session moves it; a deleted end stays where it + was as a point. `board.label {id, text}` sets its label (empty removes it). +- `board.reattach {id?, end: from|to, target, side?}` → `{id}`: moves one end of a connector + (default the selected one; its label stands for it) onto `target`, a layer id or an `{x, y}` + page point where the end stays free. Refused when both ends would be on one layer or the target + is a connector. Dragging a selected connector's end handle in the app does the same. - `board.stamp {x, y, stamp: thumbsUp|heart|plusOne|star|question|check}` → `{id}`: a round badge centred there, with your name on it for hover. - `board.ink {path | points, highlighter?, color?, select? (default true)}` → `{id}`: a marker (or wide, see-through highlighter) stroke. - `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as - their line. `board.setStyle {ids?, shape?, fontSize?, style?, startCap?, endCap?}`: a shape's - outline and text size, a connector's routing and arrowheads. -- Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?}` + their line. `board.setStyle {ids?, shape?, fontSize?, style?: straight|elbow|curved, startCap?, + endCap?}`: a shape's outline and text size, a connector's routing and arrowheads. +- Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?: straight|elbow|curved}` (drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`, `tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 62bea024..37c9ff53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; 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-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; 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 @@ -117,7 +117,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 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 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| 22 | Boards (whiteboarding) | 0 | ~35% | ~20% | Board files with sticky notes, shapes with text, connectors that follow what they join, marker, highlighter and eraser, stamps, text and sections, in live sessions (M12); missing: timer, voting, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 30–60 | +| 22 | Boards (whiteboarding) | 0 | ~37% | ~21% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, in live sessions (M12); missing: timer, voting, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 28–56 | | | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 |