mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
M12: boards dot grid, re-attaching connector ends, curved connectors (#1249)
* M12: boards dot grid, re-attaching connector ends, curved connectors Board pages draw a dot grid in the canvas render, after the page colour and under the layers (figcraft_render::Dots), thinned out when zoomed out and coloured from the theme's ink against the page. A selected connector shows its ends as handles; dragging one onto an object re-attaches it, 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. * M12: boards dot grid: blending a dot in a function of its own (Sonar)
This commit is contained in:
+4
-2
@@ -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-25 · **Change:** minor (Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -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
|
||||
|
||||
@@ -134,6 +134,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **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** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
|
||||
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
|
||||
- **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)).
|
||||
@@ -340,6 +341,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 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 | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
|
||||
| 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 |
|
||||
|
||||
+18
-3
@@ -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> {
|
||||
[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.
|
||||
|
||||
+238
-51
@@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
}
|
||||
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<Stamp> {
|
||||
}
|
||||
|
||||
fn routing_of(cmd: &str, v: &str) -> Result<Routing> {
|
||||
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<Option<StrokeCap>> {
|
||||
@@ -606,7 +614,7 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<End> {
|
||||
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<NodeId> {
|
||||
let parent = doc.node(id)?.parent?;
|
||||
@@ -664,12 +746,7 @@ fn label_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId>
|
||||
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<Point> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
let pts: Vec<Point> = network.vertices.iter().map(|v| w * v.point()).collect();
|
||||
let mut pts: Vec<Point> = 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<Point> {
|
||||
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<Rect> {
|
||||
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<Point> = 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<Point> = 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::<Vec<_>>(), 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<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| match e {
|
||||
@@ -1067,25 +1176,36 @@ pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
|
||||
};
|
||||
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::<Vec<_>>()),
|
||||
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<Point> = 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<Rect>, other: Point) -> Option<(Point, Option<bool>)> {
|
||||
/// `pts` without repeats or turns that don't turn (three in a line across or down).
|
||||
fn tidy(pts: Vec<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = 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<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
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<Rect>, other: Point) -> Option<(Point, Option<bool>
|
||||
}
|
||||
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<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
/// 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<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| 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<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
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<Side>| 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<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + 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<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
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<()> {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1636,6 +1636,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;
|
||||
|
||||
@@ -465,6 +465,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<NodeId>,
|
||||
@@ -745,6 +752,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));
|
||||
@@ -1215,6 +1226,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 {
|
||||
@@ -1277,8 +1291,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)
|
||||
@@ -1637,6 +1656,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 };
|
||||
@@ -2062,6 +2086,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()?,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 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 rgba = if i % 2 == 0 && j % 2 == 0 { strong } else { faded };
|
||||
blend_square(data, row, (xa, xb), (ya, yb), rgba);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend premultiplied `rgba` source-over into the pixels `xs` × `ys` of `data`, rows `row` bytes
|
||||
/// long.
|
||||
fn blend_square(data: &mut [u8], row: usize, (xa, xb): (i64, i64), (ya, yb): (i64, i64), rgba: [u32; 4]) {
|
||||
let keep = 255 - rgba[3];
|
||||
if rgba[3] == 0 {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
for (dst, src) in px.iter_mut().zip(rgba) {
|
||||
*dst = (src + u32::from(*dst) * keep / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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<Dots>,
|
||||
}
|
||||
|
||||
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,7 @@ struct Frame {
|
||||
skip: Vec<NodeId>,
|
||||
solo: Option<NodeId>,
|
||||
background: Option<figcraft_color::Color>,
|
||||
dots: Option<crate::Dots>,
|
||||
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,
|
||||
|
||||
@@ -451,6 +451,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "ইফেক্ট সেটিংস"
|
||||
"Effect styles" = "ইফেক্ট স্টাইল"
|
||||
"Effects" = "ইফেক্টসমূহ"
|
||||
"Curved" = "বাঁকা"
|
||||
"Elbow" = "কোণযুক্ত"
|
||||
"Ellipse" = "উপবৃত্ত"
|
||||
"Ellipses" = "উপবৃত্তসমূহ"
|
||||
@@ -1785,6 +1786,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "মার্কারের টান"
|
||||
"board.label" = "সংযোগকারীর লেবেল"
|
||||
"board.nextSticky" = "পরের স্টিকি নোট"
|
||||
"board.reattach" = "সংযোগকারী পুনরায় যুক্ত করুন"
|
||||
"board.setColor" = "বোর্ডের রং"
|
||||
"board.setStyle" = "বোর্ডের শৈলী"
|
||||
"board.shape" = "লেখাসহ আকৃতি"
|
||||
|
||||
@@ -438,6 +438,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"
|
||||
@@ -1773,6 +1774,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"
|
||||
|
||||
@@ -433,6 +433,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Effekteinstellungen"
|
||||
"Effect styles" = "Effektstile"
|
||||
"Effects" = "Effekte"
|
||||
"Curved" = "Gebogen"
|
||||
"Elbow" = "Gewinkelt"
|
||||
"Ellipse" = "Ellipse"
|
||||
"Ellipses" = "Ellipsen"
|
||||
@@ -1729,6 +1730,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"
|
||||
|
||||
@@ -463,6 +463,7 @@ name = "English"
|
||||
"Effect settings" = "Effect settings"
|
||||
"Effect styles" = "Effect styles"
|
||||
"Effects" = "Effects"
|
||||
"Curved" = "Curved"
|
||||
"Elbow" = "Elbow"
|
||||
"Ellipse" = "Ellipse"
|
||||
"Ellipses" = "Ellipses"
|
||||
|
||||
@@ -459,6 +459,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"
|
||||
@@ -1847,6 +1848,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"
|
||||
|
||||
@@ -453,6 +453,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Tehosteen asetukset"
|
||||
"Effect styles" = "Tehostetyylit"
|
||||
"Effects" = "Tehosteet"
|
||||
"Curved" = "Kaareva"
|
||||
"Elbow" = "Kulmikas"
|
||||
"Ellipse" = "Ellipsi"
|
||||
"Ellipses" = "Ellipsit"
|
||||
@@ -1815,6 +1816,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"
|
||||
|
||||
@@ -452,6 +452,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"
|
||||
@@ -1816,6 +1817,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"
|
||||
|
||||
@@ -442,6 +442,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Pengaturan efek"
|
||||
"Effect styles" = "Gaya efek"
|
||||
"Effects" = "Efek"
|
||||
"Curved" = "Lengkung"
|
||||
"Elbow" = "Siku"
|
||||
"Ellipse" = "Elips"
|
||||
"Ellipses" = "Elips"
|
||||
@@ -1777,6 +1778,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"
|
||||
|
||||
@@ -447,6 +447,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "エフェクトの設定"
|
||||
"Effect styles" = "エフェクトスタイル"
|
||||
"Effects" = "エフェクト"
|
||||
"Curved" = "曲線"
|
||||
"Elbow" = "直角"
|
||||
"Ellipse" = "楕円"
|
||||
"Ellipses" = "楕円"
|
||||
@@ -1796,6 +1797,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "マーカーの線"
|
||||
"board.label" = "コネクタのラベル"
|
||||
"board.nextSticky" = "次の付箋"
|
||||
"board.reattach" = "コネクタを付け替え"
|
||||
"board.setColor" = "ボードの色"
|
||||
"board.setStyle" = "ボードのスタイル"
|
||||
"board.shape" = "テキスト付きシェイプ"
|
||||
|
||||
@@ -453,6 +453,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Effect settings" = "ეფექტის პარამეტრები"
|
||||
"Effect styles" = "ეფექტის სტილები"
|
||||
"Effects" = "ეფექტები"
|
||||
"Curved" = "მრუდი"
|
||||
"Elbow" = "კუთხოვანი"
|
||||
"Ellipse" = "ელიფსი"
|
||||
"Ellipses" = "ელიფსები"
|
||||
@@ -1811,6 +1812,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" = "ფიგურა ტექსტით"
|
||||
|
||||
@@ -429,6 +429,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "효과 설정"
|
||||
"Effect styles" = "효과 스타일"
|
||||
"Effects" = "효과"
|
||||
"Curved" = "곡선"
|
||||
"Elbow" = "꺾은선"
|
||||
"Ellipse" = "타원"
|
||||
"Ellipses" = "타원"
|
||||
@@ -1720,6 +1721,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "마커 획"
|
||||
"board.label" = "커넥터 레이블"
|
||||
"board.nextSticky" = "다음 스티커 메모"
|
||||
"board.reattach" = "커넥터 다시 연결"
|
||||
"board.setColor" = "보드 색상"
|
||||
"board.setStyle" = "보드 스타일"
|
||||
"board.shape" = "텍스트가 있는 도형"
|
||||
|
||||
@@ -453,6 +453,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Effectinstellingen"
|
||||
"Effect styles" = "Effectstijlen"
|
||||
"Effects" = "Effecten"
|
||||
"Curved" = "Gebogen"
|
||||
"Elbow" = "Haaks"
|
||||
"Ellipse" = "Ellips"
|
||||
"Ellipses" = "Ellipsen"
|
||||
@@ -1814,6 +1815,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"
|
||||
|
||||
@@ -452,6 +452,7 @@ name = "Polski"
|
||||
"Effect settings" = "Ustawienia efektów"
|
||||
"Effect styles" = "Style efektów"
|
||||
"Effects" = "Efekty"
|
||||
"Curved" = "Zakrzywiony"
|
||||
"Elbow" = "Łamany"
|
||||
"Ellipse" = "Elipsa"
|
||||
"Ellipses" = "Elipsy"
|
||||
@@ -1827,6 +1828,7 @@ name = "Polski"
|
||||
"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"
|
||||
|
||||
@@ -428,6 +428,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"
|
||||
@@ -1833,6 +1834,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"
|
||||
|
||||
@@ -452,6 +452,7 @@ name = "Русский"
|
||||
"Effect settings" = "Настройки эффекта"
|
||||
"Effect styles" = "Стили эффектов"
|
||||
"Effects" = "Эффекты"
|
||||
"Curved" = "Кривая"
|
||||
"Elbow" = "Ломаная"
|
||||
"Ellipse" = "Эллипс"
|
||||
"Ellipses" = "Эллипсы"
|
||||
@@ -1827,6 +1828,7 @@ name = "Русский"
|
||||
"board.ink" = "Штрих маркера"
|
||||
"board.label" = "Подпись соединителя"
|
||||
"board.nextSticky" = "Следующий стикер"
|
||||
"board.reattach" = "Переподключить соединитель"
|
||||
"board.setColor" = "Цвет на доске"
|
||||
"board.setStyle" = "Стиль на доске"
|
||||
"board.shape" = "Фигура с текстом"
|
||||
|
||||
@@ -452,6 +452,7 @@ name = "Svenska"
|
||||
"Effect settings" = "Effektinställningar"
|
||||
"Effect styles" = "Effektstilar"
|
||||
"Effects" = "Effekter"
|
||||
"Curved" = "Böjd"
|
||||
"Elbow" = "Vinklad"
|
||||
"Ellipse" = "Ellips"
|
||||
"Ellipses" = "Ellipser"
|
||||
@@ -1828,6 +1829,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"
|
||||
|
||||
@@ -442,6 +442,7 @@ name = "Türkçe"
|
||||
"Effect settings" = "Efekt ayarları"
|
||||
"Effect styles" = "Efekt stilleri"
|
||||
"Effects" = "Efektler"
|
||||
"Curved" = "Eğri"
|
||||
"Elbow" = "Dirsekli"
|
||||
"Ellipse" = "Elips"
|
||||
"Ellipses" = "Elipsler"
|
||||
@@ -1777,6 +1778,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"
|
||||
|
||||
@@ -433,6 +433,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "效果设置"
|
||||
"Effect styles" = "效果样式"
|
||||
"Effects" = "效果"
|
||||
"Curved" = "曲线"
|
||||
"Elbow" = "折线"
|
||||
"Ellipse" = "椭圆"
|
||||
"Ellipses" = "椭圆"
|
||||
@@ -1728,6 +1729,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "马克笔笔画"
|
||||
"board.label" = "连接线标签"
|
||||
"board.nextSticky" = "下一张便利贴"
|
||||
"board.reattach" = "重新连接连接线"
|
||||
"board.setColor" = "白板颜色"
|
||||
"board.setStyle" = "白板样式"
|
||||
"board.shape" = "带文字的形状"
|
||||
|
||||
@@ -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<figcraft_render::Dots> {
|
||||
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<Pos2>) {
|
||||
let (Some(at), Ok(ds)) = (pointer, app.session.ds()) else { return };
|
||||
|
||||
@@ -143,7 +143,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,
|
||||
@@ -152,6 +152,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());
|
||||
@@ -211,3 +214,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<figcraft_render::Dots>,
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
@@ -203,7 +204,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();
|
||||
@@ -235,17 +248,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) {
|
||||
@@ -361,7 +363,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<figcraft_render::Dots>) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let rect = app.canvas.rect;
|
||||
let (mut ox, mut oy) = ds.view.origin();
|
||||
@@ -400,6 +402,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);
|
||||
@@ -422,6 +425,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(),
|
||||
@@ -1087,6 +1091,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 {
|
||||
@@ -1099,16 +1105,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);
|
||||
@@ -1224,6 +1244,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 {
|
||||
|
||||
@@ -63,6 +63,7 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale:
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
proof: false,
|
||||
dots: None,
|
||||
uid: 0,
|
||||
zoom_preview: false,
|
||||
at: web_time::Instant::now(),
|
||||
|
||||
@@ -208,6 +208,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,
|
||||
/// A measurement: a dimension line with arrowheads between two end ticks.
|
||||
@@ -1181,6 +1183,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);
|
||||
|
||||
@@ -236,6 +236,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(),
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct Job {
|
||||
pub solo: Option<NodeId>,
|
||||
/// Soft-proof sRGB: stripe the colours sRGB can't show.
|
||||
pub proof: bool,
|
||||
/// A board's dot grid under the layers.
|
||||
pub dots: Option<figcraft_render::Dots>,
|
||||
/// 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)?;
|
||||
|
||||
@@ -72,6 +72,7 @@ fn job(doc: &Arc<Document>, page: NodeId, uid: u64, view: View) -> Job {
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
proof: false,
|
||||
dots: None,
|
||||
uid,
|
||||
zoom_preview: true,
|
||||
at: web_time::Instant::now(),
|
||||
|
||||
+13
-10
@@ -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 |
|
||||
|
||||
+9
-5
File diff suppressed because one or more lines are too long
+12
-7
@@ -396,20 +396,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.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user