mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M6: .fig connector labels without a saved snapshot are kept, centred on their line (#1659)
* M6: .fig connector labels without a saved snapshot are kept, centred on their line A connector with its own label text but no saved snapshot imports as a frame holding its line (a vector with the connector's stroke and ends) and the label as text centred on the line's middle by length, in the connector's fill paints or black; its background isn't known and is left out, as the import report says. The connector branch of layer_filtered moves into connector_layer. Gap 1. * Docs: drop a status item the merge listed twice
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -3,21 +3,59 @@ use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_rec
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
|
||||
/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its
|
||||
/// label), or drawn from its ends, as a frame with its line and `label` when it has one.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ConnectorShape {
|
||||
pub snapshot: bool,
|
||||
pub terminal: bool,
|
||||
pub label: bool,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// A connector's own properties on `o`; returns the children it adds (a line and a label).
|
||||
pub(super) fn connector_layer(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine, shape: ConnectorShape) -> Vec<Value> {
|
||||
if shape.snapshot {
|
||||
if shape.terminal {
|
||||
self.connector_snapshot_flatten(o, v, rest_id, space);
|
||||
} else {
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] {
|
||||
o.remove(key);
|
||||
}
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
let mid = self.connector(o, v, space);
|
||||
if shape.label {
|
||||
return self.connector_label(o, v, rest_id, mid);
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Whether a connector without a snapshot keeps its label: it has text of its own and there is
|
||||
/// room for its two children under the depth and layer limits.
|
||||
pub(super) fn connector_label_fits(&self, v: &View<'a>, depth: usize) -> bool {
|
||||
own_text(v).is_some() && depth < super::MAX_DEPTH && self.count.saturating_add(2) < self.budget()
|
||||
}
|
||||
|
||||
/// A connector's end caps, and when it has no saved outline or network its line between its
|
||||
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
|
||||
/// Returns the middle of that line by length, in the connector's own coordinates.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option<Point> {
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
if let Some(c) = v.str(from) {
|
||||
o.insert(to.into(), json!(cap_name(c)));
|
||||
}
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let routing = match v.str("connectorLineStyle") {
|
||||
Some("ELBOWED") => Routing::Elbow,
|
||||
@@ -56,7 +94,7 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
if routing != Routing::Straight {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
@@ -81,6 +119,55 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let vertices: Vec<Value> = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect();
|
||||
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments}));
|
||||
middle(&path).map(|m| m - b.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// A connector with a label but no saved snapshot of it, as the frame a saved one imports as:
|
||||
/// `o` keeps the connector's place and size and gives its line to a vector child, and the
|
||||
/// label is a text child centred on `mid` (the line's middle; the frame's centre when the line
|
||||
/// came saved). Returns the two children.
|
||||
pub(super) fn connector_label(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, mid: Option<Point>) -> Vec<Value> {
|
||||
let (w, h) = o.get("size").map_or((0.0, 0.0), |s| (s["x"].as_f64().unwrap_or(0.0), s["y"].as_f64().unwrap_or(0.0)));
|
||||
let mut line = Obj::new();
|
||||
line.insert("id".into(), json!(format!("{rest_id};line")));
|
||||
line.insert("type".into(), json!("VECTOR"));
|
||||
line.insert("name".into(), json!(format!("{} line", v.str("name").unwrap_or("Connector"))));
|
||||
line.insert("size".into(), json!({"x": w, "y": h}));
|
||||
line.insert("relativeTransform".into(), rows(Affine::IDENTITY));
|
||||
// The connector's fill paints colour its label; the line is only stroked.
|
||||
let label_fills = o.remove("fills").filter(|f| f.as_array().is_some_and(|a| !a.is_empty()));
|
||||
for key in LINE_KEYS {
|
||||
if let Some(value) = o.remove(*key) {
|
||||
line.insert((*key).into(), value);
|
||||
}
|
||||
}
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
|
||||
let mut text = Obj::new();
|
||||
text.insert("id".into(), json!(format!("{rest_id};label")));
|
||||
text.insert("type".into(), json!("TEXT"));
|
||||
self.text(&mut text, v);
|
||||
let characters = text.get("characters").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
text.insert("name".into(), json!(characters));
|
||||
text.insert("fills".into(), label_fills.unwrap_or_else(|| json!([{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}])));
|
||||
// Centred on the line's middle in a box of the label's width, as tall as its lines (laid out
|
||||
// again with the fonts at hand, so the box only centres it).
|
||||
let size = text.get("style").and_then(|s| s["fontSize"].as_f64()).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(16.0);
|
||||
let lines = characters.lines().count().max(1) as f64;
|
||||
let (bw, bh) = (LABEL_WIDTH, lines * size * 1.4);
|
||||
if let Some(style) = text.get_mut("style").and_then(Value::as_object_mut) {
|
||||
style.insert("textAlignHorizontal".into(), json!("CENTER"));
|
||||
style.insert("textAlignVertical".into(), json!("CENTER"));
|
||||
style.insert("textAutoResize".into(), json!("NONE"));
|
||||
}
|
||||
let at = mid.unwrap_or(Point::new(w / 2.0, h / 2.0));
|
||||
text.insert("size".into(), json!({"x": bw, "y": bh}));
|
||||
text.insert("relativeTransform".into(), rows(Affine::translate((at.x - bw / 2.0, at.y - bh / 2.0))));
|
||||
self.count += 2;
|
||||
self.warn("connector labels without a saved snapshot are centred on the line, without their background");
|
||||
vec![Value::Object(line), Value::Object(text)]
|
||||
}
|
||||
|
||||
pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool {
|
||||
@@ -311,3 +398,45 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connector's line carries, moved to the line child when the connector becomes a frame.
|
||||
const LINE_KEYS: &[&str] = &[
|
||||
"vectorNetwork",
|
||||
"vectorNetworkRef",
|
||||
"fillGeometry",
|
||||
"strokeGeometry",
|
||||
"fills",
|
||||
"strokes",
|
||||
"strokeWeight",
|
||||
"strokeAlign",
|
||||
"strokeJoin",
|
||||
"strokeDashes",
|
||||
"strokeCap",
|
||||
"strokeCapStart",
|
||||
"strokeCapEnd",
|
||||
"strokeWidthProfile",
|
||||
"styles",
|
||||
];
|
||||
|
||||
/// How wide a label without a saved snapshot is laid out, centred on its connector.
|
||||
const LABEL_WIDTH: f64 = 200.0;
|
||||
|
||||
/// The point halfway along `path` by length.
|
||||
fn middle(path: &BezPath) -> Option<Point> {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
figcraft_doc::geom::kurbo::flatten(path.iter(), 0.25, |el| {
|
||||
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
|
||||
pts.push(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) {
|
||||
let l = (s[1] - s[0]).hypot();
|
||||
if l >= left && l > 0.0 {
|
||||
return Some(s[0].lerp(s[1], left / l));
|
||||
}
|
||||
left -= l;
|
||||
}
|
||||
pts.first().copied()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::component::{Entry, Inst, OverrideRow, prop_ref};
|
||||
use super::connector::connector_has_saved_label;
|
||||
use super::connector::{ConnectorShape, connector_has_saved_label};
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
|
||||
use super::{
|
||||
@@ -155,7 +155,9 @@ impl<'a> Mapper<'a> {
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
|
||||
// A label without a snapshot: the connector becomes a frame holding its line and its label.
|
||||
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
|
||||
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
|
||||
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
|
||||
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
|
||||
}
|
||||
@@ -206,20 +208,12 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if own == "CONNECTOR" && connector_snapshot {
|
||||
if connector_terminal {
|
||||
self.connector_snapshot_flatten(&mut o, &view, &rest_id, space);
|
||||
} else {
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] {
|
||||
o.remove(key);
|
||||
}
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
}
|
||||
} else if own == "CONNECTOR" {
|
||||
self.connector(&mut o, &view, space);
|
||||
}
|
||||
let connector_kids = if own == "CONNECTOR" {
|
||||
let shape = ConnectorShape { snapshot: connector_snapshot, terminal: connector_terminal, label: connector_label };
|
||||
self.connector_layer(&mut o, &view, &rest_id, space, shape)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if ty == "COMPONENT" || ty == "COMPONENT_SET" {
|
||||
self.component(&mut o, &view, &rest_id);
|
||||
}
|
||||
@@ -250,6 +244,7 @@ impl<'a> Mapper<'a> {
|
||||
included,
|
||||
slot_content,
|
||||
});
|
||||
kids.splice(0..0, connector_kids);
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
kids.splice(0..0, snapshot);
|
||||
|
||||
@@ -666,6 +666,45 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
|
||||
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A connector with a label but no saved snapshot: a frame holding its line and its label,
|
||||
/// centred on the line's middle, in black when the connector gives no paint.
|
||||
#[test]
|
||||
fn a_labelled_connector_without_a_snapshot_keeps_its_label() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
|
||||
let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]);
|
||||
let labelled = node(
|
||||
62,
|
||||
Some((1, "+")),
|
||||
"CONNECTOR",
|
||||
"Says",
|
||||
vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", side(60, 1.0)),
|
||||
("connectorEnd", side(61, 0.0)),
|
||||
("connectorLineStyle", e("STRAIGHT")),
|
||||
("textData", text_data("yes")),
|
||||
("fontSize", f(20.0)),
|
||||
],
|
||||
);
|
||||
nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), labelled]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Labels").unwrap();
|
||||
let says = named(&doc, "Says");
|
||||
assert!(matches!(says.kind, NodeKind::Frame), "{:?}", says.kind);
|
||||
assert!(says.fills.is_empty() && says.strokes.is_empty(), "the frame only holds the line and the label");
|
||||
let kids: Vec<&figcraft_doc::Node> = says.children.iter().map(|c| doc.node(*c).unwrap()).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].strokes.len() == 1, "the line keeps its stroke");
|
||||
assert!(matches!(&kids[1].kind, NodeKind::Text(t) if t.characters == "yes"), "{:?}", kids[1].kind);
|
||||
assert_eq!(kids[1].fills.len(), 1, "black when the connector gives no paint");
|
||||
// Centred on the line's middle, (200, 650) on the page.
|
||||
let label = doc.world_transform(kids[1].id) * figcraft_doc::geom::Point::new(kids[1].width / 2.0, kids[1].height / 2.0);
|
||||
assert!((label.x - 200.0).abs() < 1e-6 && (label.y - 650.0).abs() < 1e-6, "{label:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("centred on the line")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("connector labels were skipped")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_prototype_actions_import() {
|
||||
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
|
||||
|
||||
@@ -1710,6 +1710,7 @@ name = "English"
|
||||
"connector label was skipped: text style data is missing" = "connector label was skipped: text style data is missing"
|
||||
"connector label was skipped: text transform is invalid" = "connector label was skipped: text transform is invalid"
|
||||
"connector labels were skipped" = "connector labels were skipped"
|
||||
"connector labels without a saved snapshot are centred on the line, without their background" = "connector labels without a saved snapshot are centred on the line, without their background"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "connector snapshot vector data is missing; connector was left empty"
|
||||
"connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
@@ -100,8 +100,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
so the differences come from the fonts' advances. Evidence:
|
||||
`crates/text/src/saved_breaks_tests.rs`, `crates/format/src/fig/text_wrap_tests.rs`; not
|
||||
checked on real files.
|
||||
- **Missing:** labels of
|
||||
connectors without a saved snapshot, table cells beyond their own layers, live region
|
||||
- **Missing:** the background of connector labels without a saved snapshot (the label is kept,
|
||||
centred on its line), table cells beyond their own layers, live region
|
||||
style/variable links, variables
|
||||
bound to fields the editor can't bind and video fills aren't read; text lays out again with
|
||||
local fonts (an unedited text keeps the file's line breaks, but glyph positions, line widths
|
||||
@@ -571,6 +571,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
|
||||
| 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. |
|
||||
| 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` |
|
||||
| 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` |
|
||||
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M6, gap 1: `.fig` connector labels without a saved snapshot are kept, centred on the line, instead of skipped. Test: `fig::tests::tail` |
|
||||
| 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. |
|
||||
| 2026-10-30 | minor | M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets; an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, still skipping damaged faces and reading later faces' name tables without reading outlines. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. |
|
||||
|
||||
Reference in New Issue
Block a user