mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 04:39:38 -06:00
Merge pull request #1987 from TrevorBrown-dev/fix/jam-connector-control-points
FigJam import: an elbow connector saved with its path keeps it instead of being routed again (#1910)
This commit is contained in:
@@ -72,6 +72,12 @@ pub fn attached_sides(from: RouteEnd, to: RouteEnd) -> (Option<Side>, Option<Sid
|
||||
(attach(from, to.centre()).1, attach(to, from.centre()).1)
|
||||
}
|
||||
|
||||
/// Where a connector's ends attach, as routed now: the points [`ConnectorPath::Runs`] is measured
|
||||
/// from the middle of.
|
||||
pub fn attach_points(from: RouteEnd, to: RouteEnd) -> (Point, Point) {
|
||||
(attach(from, to.centre()).0, attach(to, from.centre()).0)
|
||||
}
|
||||
|
||||
/// The route's path handles: one for each run of an elbow and one for a curve, with the portion
|
||||
/// of the path each moves and where it sits.
|
||||
pub fn path_handles(pts: &[Point], routing: Routing) -> Vec<(usize, Point)> {
|
||||
@@ -96,8 +102,9 @@ pub fn reshaped(from: RouteEnd, to: RouteEnd, routing: Routing, path: Option<&Co
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another.
|
||||
const MAX_RUNS: usize = 64;
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another, and an
|
||||
/// importer leaves a saved one to the router.
|
||||
pub const MAX_RUNS: usize = 64;
|
||||
|
||||
/// The runs of the right-angled route `pts` with run `portion` moved to `p`.
|
||||
fn reshaped_runs(pts: &[Point], portion: usize, p: Point) -> Option<ConnectorPath> {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use board_route::{END_GAP, MAX_RUNS, RouteEnd, attach_points, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::paint::{paint, stroke};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
use figcraft_doc::{ConnectorPath, MAX_RUNS, RouteEnd, Routing, Side, attach_points, attached_sides, route_along};
|
||||
|
||||
/// 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.
|
||||
@@ -48,7 +48,8 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// 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.
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs;
|
||||
/// an elbow saved with its path (FigJam's control points) runs along it (#1910).
|
||||
/// 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")] {
|
||||
@@ -94,11 +95,13 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
ok.then_some(end)
|
||||
};
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
let ends = end("connectorStart").zip(end("connectorEnd"));
|
||||
let path = ends.filter(|_| routing == Routing::Elbow).and_then(|(a, b)| saved_runs(v, a, b));
|
||||
let Some(pts) = ends.and_then(|(a, b)| route_along(a, b, routing, path.as_ref())) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return None;
|
||||
};
|
||||
if routing != Routing::Straight {
|
||||
if routing != Routing::Straight && path.is_none() {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
}
|
||||
// A curved route is one curve: its ends and the two control points between them.
|
||||
@@ -407,6 +410,49 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The path a FigJam elbow connector was saved with: its `connectorControlPoints`, one per run
|
||||
/// between the first and the last, each a point on its run (in the connector's parent's
|
||||
/// coordinates, like its ends) with the run's axis; the first run leaves the start's side and the
|
||||
/// last arrives at the end's, so neither is saved. `None` without any, or when they don't make
|
||||
/// such a path: a point off both axes, two runs in a row the same way, a first or last run that
|
||||
/// would leave or arrive sideways to its side, or more runs than a hand could make here.
|
||||
fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPath> {
|
||||
let saved = v.list("connectorControlPoints");
|
||||
if saved.is_empty() || saved.len() + 2 > MAX_RUNS {
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = attach_points(from, to);
|
||||
let mid = p0.midpoint(p1);
|
||||
let mut at = Vec::with_capacity(saved.len());
|
||||
let (mut first, mut last) = (None, None);
|
||||
for p in saved {
|
||||
let pos = p.get("position").and_then(|q| Some(Point::new(num(q, "x")?, num(q, "y")?)))?;
|
||||
let (ax, ay) = p.get("axis").and_then(|q| Some((num(q, "x")?, num(q, "y")?)))?;
|
||||
let sideways = match (ax != 0.0, ay != 0.0) {
|
||||
(true, false) => true,
|
||||
(false, true) => false,
|
||||
_ => return None,
|
||||
};
|
||||
if last == Some(sideways) {
|
||||
return None;
|
||||
}
|
||||
first.get_or_insert(!sideways);
|
||||
last = Some(sideways);
|
||||
at.push(if sideways { pos.y - mid.y } else { pos.x - mid.x });
|
||||
}
|
||||
// The run through each end goes the way its side faces, out of the object: the first leaves
|
||||
// the start's side for the first saved run, the last arrives at the end's from the last saved
|
||||
// run. One along the side, or back into the object, doesn't.
|
||||
let (across, last) = (first?, last?);
|
||||
let (&a0, &a1) = (at.first()?, at.last()?);
|
||||
// Where the run from `p` (`sideways`: going across) meets the saved run at `v`.
|
||||
let onto = |sideways: bool, p: Point, v: f64| if sideways { Point::new(mid.x + v, p.y) } else { Point::new(p.x, mid.y + v) };
|
||||
let out_of = |s: Option<Side>, p: Point, q: Point| s.is_none_or(|s| (q - p).dot(s.normal()) > 0.0);
|
||||
let (s0, s1) = attached_sides(from, to);
|
||||
let fits = out_of(s0, p0, onto(across, p0, a0)) && out_of(s1, p1, onto(!last, p1, a1));
|
||||
fits.then_some(ConnectorPath::Runs { across, at })
|
||||
}
|
||||
|
||||
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
|
||||
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
|
||||
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
|
||||
|
||||
@@ -863,3 +863,85 @@ fn write_fixture() {
|
||||
std::fs::write(out, zip_file(&tail_canvas())).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// An elbow connector saved with its path (#1910): FigJam's control points, one per run between
|
||||
/// the first and the last, each on its run with the run's axis, give the path the connector keeps;
|
||||
/// the first run leaves the start's side and the last arrives at the end's.
|
||||
#[test]
|
||||
fn an_elbow_connector_saved_with_its_path_keeps_it() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x, y| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, y))]);
|
||||
let point = |x, y, ax, ay| o(vec![("position", xy(x, y)), ("axis", xy(ax, ay))]);
|
||||
// From A's bottom, across at y 740, down at x 200, into B's left: four runs.
|
||||
let saved = node(
|
||||
66,
|
||||
Some((1, "+")),
|
||||
"CONNECTOR",
|
||||
"Saved",
|
||||
vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", o(vec![("endpointNodeID", g(64)), ("relativePosition", xy(0.5, 1.0))])),
|
||||
("connectorEnd", o(vec![("endpointNodeID", g(65)), ("relativePosition", xy(0.0, 0.5))])),
|
||||
("connectorLineStyle", e("ELBOWED")),
|
||||
("connectorControlPoints", arr(vec![point(150.0, 740.0, 1.0, 0.0), point(200.0, 800.0, 0.0, 1.0)])),
|
||||
],
|
||||
);
|
||||
nodes.extend([box_at(64, "A", 0.0, 600.0), box_at(65, "B", 300.0, 800.0), saved]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Saved path").unwrap();
|
||||
let world = doc.world_transform(named(&doc, "Saved").id);
|
||||
let line: Vec<_> =
|
||||
network_of(&doc, "Saved").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
// Each end starts `END_GAP` (4) outside its side's border.
|
||||
assert_eq!(line, [(50.0, 704.0), (50.0, 740.0), (200.0, 740.0), (200.0, 850.0), (296.0, 850.0)]);
|
||||
// The fixture's own Link, saved without a path, is routed here: the note counts it alone.
|
||||
let routed: Vec<_> = warnings.iter().filter(|w| w.contains("FigCraft's own routing")).collect();
|
||||
assert_eq!(routed.len(), 1, "{warnings:?}");
|
||||
assert!(!routed[0].contains('×'), "a connector with its path isn't counted among those routed here: {routed:?}");
|
||||
}
|
||||
|
||||
/// Saved control points that don't make a path from the connector's sides (#1910): a first run
|
||||
/// that would leave the bottom sideways, a point off both axes, or two runs in a row the same way
|
||||
/// leave the connector to FigCraft's routing, as one saved without a path is.
|
||||
#[test]
|
||||
fn an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x, y| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, y))]);
|
||||
let point = |x, y, ax, ay| o(vec![("position", xy(x, y)), ("axis", xy(ax, ay))]);
|
||||
let elbow = |id, name: &str, points: Vec<Kv<'static>>| {
|
||||
let mut fields = vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", o(vec![("endpointNodeID", g(64)), ("relativePosition", xy(0.5, 1.0))])),
|
||||
("connectorEnd", o(vec![("endpointNodeID", g(65)), ("relativePosition", xy(0.0, 0.5))])),
|
||||
("connectorLineStyle", e("ELBOWED")),
|
||||
];
|
||||
if !points.is_empty() {
|
||||
fields.push(("connectorControlPoints", arr(points)));
|
||||
}
|
||||
node(id, Some((1, "+")), "CONNECTOR", name, fields)
|
||||
};
|
||||
nodes.extend([
|
||||
box_at(64, "A", 0.0, 600.0),
|
||||
box_at(65, "B", 300.0, 800.0),
|
||||
elbow(66, "Plain", vec![]),
|
||||
elbow(67, "Sideways", vec![point(100.0, 650.0, 0.0, 1.0)]),
|
||||
elbow(68, "Askew", vec![point(150.0, 740.0, 1.0, 1.0)]),
|
||||
elbow(69, "Repeated", vec![point(150.0, 740.0, 1.0, 0.0), point(150.0, 760.0, 1.0, 0.0)]),
|
||||
// The first run would go up from A's bottom, through A; the last would arrive at B's left
|
||||
// from inside B. Each run is at right angles to its side, so only the way it goes is wrong.
|
||||
elbow(70, "Backwards", vec![point(100.0, 650.0, 1.0, 0.0), point(200.0, 740.0, 0.0, 1.0)]),
|
||||
elbow(71, "Inside", vec![point(150.0, 740.0, 1.0, 0.0), point(350.0, 800.0, 0.0, 1.0)]),
|
||||
]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Unfit paths").unwrap();
|
||||
let line = |name: &str| -> Vec<(f64, f64)> {
|
||||
let world = doc.world_transform(named(&doc, name).id);
|
||||
network_of(&doc, name).vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect()
|
||||
};
|
||||
let plain = line("Plain");
|
||||
assert!(plain.len() >= 3, "routed in right angles: {plain:?}");
|
||||
for name in ["Sideways", "Askew", "Repeated", "Backwards", "Inside"] {
|
||||
assert_eq!(line(name), plain, "{name} is routed as one without a path");
|
||||
}
|
||||
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ message VariableSetID GUID guid, AssetRef assetRef
|
||||
message ConditionalActions Action[] actions, VariableData condition
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
|
||||
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
|
||||
message ConnectorControlPoint Vector position, Vector axis
|
||||
message VariableData AnyValue value, DataType dataType
|
||||
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
|
||||
message ParentIndex GUID guid, string position
|
||||
@@ -74,7 +75,7 @@ message Event InteractionType interactionType
|
||||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
|
||||
message Interaction Event event, Action[] actions
|
||||
message StartingPoint string name, string position
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
|
||||
Reference in New Issue
Block a user