Support per-view connection boundary attachments in ordinary and state diagrams
This commit is contained in:
@@ -1502,11 +1502,45 @@ pub struct Diagram {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
|
||||
/// Presentation preference on one end of a connection. Real ports take precedence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeAttachment {
|
||||
pub side: PortAnchorSide,
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
|
||||
impl EdgeRoute {
|
||||
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment,
|
||||
EdgeEnd::Target => self.target_attachment,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment = attachment,
|
||||
EdgeEnd::Target => self.target_attachment = attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
|
||||
+132
-38
@@ -1,7 +1,12 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use crate::{
|
||||
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
|
||||
RelationBody, RelationKind, Size,
|
||||
};
|
||||
mod attachments;
|
||||
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
|
||||
// Derived solver state, never persisted. A preview must inherit the same
|
||||
// remaining work budget as this edge had in stable-ID settled traversal.
|
||||
search_budget_before: usize,
|
||||
attachments: [Option<EdgeAttachment>; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
CoincidentAttachments,
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
NoClearPath,
|
||||
SearchBudgetExhausted,
|
||||
@@ -181,6 +188,9 @@ pub enum RouteWarning {
|
||||
impl fmt::Display for RouteWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::CoincidentAttachments => f.write_str(
|
||||
"The two attachments coincide; move either end to separate the loop tips.",
|
||||
),
|
||||
Self::PinInsideObstacle { pin, .. } => {
|
||||
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
|
||||
}
|
||||
@@ -374,7 +384,14 @@ struct Endpoint {
|
||||
point: Position,
|
||||
stub: Position,
|
||||
}
|
||||
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
|
||||
fn endpoints(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
port: Option<Id>,
|
||||
view: &ProjectedView,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
) -> Vec<Endpoint> {
|
||||
if let Some(port) = port.and_then(|id| {
|
||||
view.ports
|
||||
.iter()
|
||||
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
),
|
||||
}];
|
||||
}
|
||||
let r = Rect::node(node);
|
||||
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
|
||||
vec![
|
||||
Endpoint {
|
||||
point: pos(r.left, mid.y),
|
||||
stub: pos(r.left - PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(r.right, mid.y),
|
||||
stub: pos(r.right + PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.top),
|
||||
stub: pos(mid.x, r.top - PORT_STUB),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.bottom),
|
||||
stub: pos(mid.x, r.bottom + PORT_STUB),
|
||||
},
|
||||
if let Some(attachment) = attachment {
|
||||
return vec![attachments::endpoint(project, diagram, node, attachment)];
|
||||
}
|
||||
[
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
|
||||
@@ -507,6 +516,12 @@ fn route_diagram_impl(
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
|
||||
semantic_port_endpoint(project, edge.id, end)
|
||||
.is_none()
|
||||
.then(|| route.and_then(|r| r.attachment(end)))
|
||||
.flatten()
|
||||
});
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
@@ -517,7 +532,10 @@ fn route_diagram_impl(
|
||||
});
|
||||
if let Some(cached) = cached {
|
||||
budget = cached.search_budget_before;
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
if cached.pins == pins
|
||||
&& cached.attachments == attachments
|
||||
&& cached.custom_suspended == custom_suspended
|
||||
{
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
for &point in &cached.points {
|
||||
@@ -543,9 +561,22 @@ fn route_diagram_impl(
|
||||
}
|
||||
let (points, pin_lengths, failure) = route_edge(
|
||||
edge,
|
||||
source,
|
||||
target,
|
||||
view,
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
source,
|
||||
edge.source_port,
|
||||
view,
|
||||
attachments[0],
|
||||
),
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
target,
|
||||
edge.target_port,
|
||||
view,
|
||||
attachments[1],
|
||||
),
|
||||
&pins,
|
||||
&obstacles,
|
||||
&previous,
|
||||
@@ -574,6 +605,7 @@ fn route_diagram_impl(
|
||||
warnings,
|
||||
custom_suspended,
|
||||
search_budget_before,
|
||||
attachments,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
|
||||
xs: Vec<f32>,
|
||||
ys: Vec<f32>,
|
||||
}
|
||||
|
||||
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
|
||||
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
|
||||
let normal = pos(
|
||||
direction(a.stub.x - a.point.x),
|
||||
direction(a.stub.y - a.point.y),
|
||||
);
|
||||
let tangent = pos(-normal.y, normal.x);
|
||||
let candidates: Vec<_> = [36., 72., 144., 288.]
|
||||
.into_iter()
|
||||
.flat_map(|extent| {
|
||||
[-1., 1.].into_iter().map(move |side| {
|
||||
[
|
||||
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
|
||||
pos(
|
||||
a.stub.x + (normal.x + tangent.x * side) * extent,
|
||||
a.stub.y + (normal.y + tangent.y * side) * extent,
|
||||
),
|
||||
pos(
|
||||
a.stub.x + tangent.x * side * extent,
|
||||
a.stub.y + tangent.y * side * extent,
|
||||
),
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// These are computed loop guards, not authored hard pins. Prefer a clear
|
||||
// orientation/size before asking the bounded router to connect them.
|
||||
candidates
|
||||
.iter()
|
||||
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|p| {
|
||||
p.iter()
|
||||
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(candidates[0])
|
||||
}
|
||||
impl<'a> RoutingSpace<'a> {
|
||||
fn new(obstacles: &'a [Rect]) -> Self {
|
||||
Self {
|
||||
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn route_edge(
|
||||
edge: &ProjectedEdge,
|
||||
source: &ProjectedNode,
|
||||
target: &ProjectedNode,
|
||||
view: &ProjectedView,
|
||||
source_ends: &[Endpoint],
|
||||
target_ends: &[Endpoint],
|
||||
pins: &[Position],
|
||||
obstacles: &[Rect],
|
||||
previous: &[Segment],
|
||||
budget: &mut usize,
|
||||
) -> EdgePath {
|
||||
// Coincident authored tips are valid presentation, including on self loops.
|
||||
// Preserve them and add an external detour; never index an empty candidate
|
||||
// list or turn the connection into an out-and-back degenerate stub.
|
||||
let coincident = edge.source == edge.target
|
||||
&& source_ends.len() == 1
|
||||
&& target_ends.len() == 1
|
||||
&& source_ends[0].point == target_ends[0].point
|
||||
&& source_ends[0].stub == target_ends[0].stub;
|
||||
let authored_pin_count = pins.len();
|
||||
let mut loop_pins = Vec::new();
|
||||
if coincident {
|
||||
let a = source_ends[0];
|
||||
loop_pins.extend_from_slice(pins);
|
||||
loop_pins.extend(coincident_detour(a, obstacles));
|
||||
}
|
||||
let pins = if coincident { &loop_pins[..] } else { pins };
|
||||
let space = RoutingSpace::new(obstacles);
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
for &a in source_ends {
|
||||
for &b in target_ends {
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
&& edge.target_port.is_none()
|
||||
&& a.point == b.point
|
||||
&& a.stub == b.stub
|
||||
&& !coincident
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -729,10 +818,9 @@ fn route_edge(
|
||||
// Try all side combinations cheaply before spending the bounded grid budget.
|
||||
for search in [false, true] {
|
||||
for &(estimate, a, b) in &candidates {
|
||||
if best
|
||||
.as_ref()
|
||||
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
|
||||
{
|
||||
if best.as_ref().is_some_and(|(cost, _, _)| {
|
||||
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if obstacles
|
||||
@@ -748,7 +836,7 @@ fn route_edge(
|
||||
let mut occupied = previous.to_vec();
|
||||
record_segments(&mut occupied, &points);
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
let mut failed = false;
|
||||
for (index, end) in pins
|
||||
@@ -792,15 +880,20 @@ fn route_edge(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, points, pin_lengths)) = best {
|
||||
return (points, pin_lengths, None);
|
||||
if let Some((_, points, mut pin_lengths)) = best {
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
return (
|
||||
points,
|
||||
pin_lengths,
|
||||
coincident.then_some(RouteWarning::CoincidentAttachments),
|
||||
);
|
||||
}
|
||||
// Recoverable fallback retains every hard pin, in order, and the actual port
|
||||
// leads. Its warning is deliberately visible to consumers.
|
||||
let (_, a, b) = candidates[0];
|
||||
let mut points = vec![a.point, a.stub];
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
for (index, end) in pins
|
||||
.iter()
|
||||
@@ -833,6 +926,7 @@ fn route_edge(
|
||||
start = end;
|
||||
}
|
||||
points.push(b.point);
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
(
|
||||
points,
|
||||
pin_lengths,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! One boundary definition shared by persisted routing and attachment gestures.
|
||||
use super::*;
|
||||
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
|
||||
|
||||
/// Semantic authority, independent of whether projection currently exposes a port.
|
||||
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
|
||||
let endpoint = if let Some(relation) = project.relations.get(&edge) {
|
||||
match (&relation.body, end) {
|
||||
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
|
||||
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
|
||||
(_, EdgeEnd::Source) => relation.source(),
|
||||
(_, EdgeEnd::Target) => relation.target(),
|
||||
}
|
||||
} else {
|
||||
let trace = project.traces.get(&edge)?;
|
||||
match end {
|
||||
EdgeEnd::Source => trace.oa,
|
||||
EdgeEnd::Target => trace.sa,
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&endpoint)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.map(|_| endpoint)
|
||||
}
|
||||
|
||||
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
|
||||
/// measured over the complete projected bounding side, including its corners.
|
||||
/// Terminate's X uses its outermost stroke intersection rather than a contour.
|
||||
pub fn attachment_point(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: &EdgeAttachment,
|
||||
) -> Position {
|
||||
let vertical = matches!(
|
||||
attachment.side,
|
||||
PortAnchorSide::Left | PortAnchorSide::Right
|
||||
);
|
||||
let (extent, depth) = if vertical {
|
||||
(node.size.height, node.size.width)
|
||||
} else {
|
||||
(node.size.width, node.size.height)
|
||||
};
|
||||
let along = attachment.offset.clamp(0., 1.) * extent;
|
||||
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
|
||||
let kind = diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.and_then(|_| project.elements.get(&node.element))
|
||||
.map(|e| e.kind());
|
||||
let inset = match kind {
|
||||
Some(Kind::Choice) => depth * 0.5 * t.abs(),
|
||||
Some(Kind::Terminate) => {
|
||||
depth
|
||||
* attachment
|
||||
.offset
|
||||
.clamp(0., 1.)
|
||||
.min(1. - attachment.offset.clamp(0., 1.))
|
||||
}
|
||||
Some(
|
||||
Kind::Initial
|
||||
| Kind::Junction
|
||||
| Kind::Final
|
||||
| Kind::ShallowHistory
|
||||
| Kind::DeepHistory
|
||||
| Kind::EntryPoint
|
||||
| Kind::ExitPoint,
|
||||
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
|
||||
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
|
||||
_ => {
|
||||
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
|
||||
let corner = (radius - along.min(extent - along)).max(0.);
|
||||
radius - (radius * radius - corner * corner).max(0.).sqrt()
|
||||
}
|
||||
};
|
||||
match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
|
||||
PortAnchorSide::Right => pos(
|
||||
node.position.x + node.size.width - inset,
|
||||
node.position.y + along,
|
||||
),
|
||||
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
|
||||
PortAnchorSide::Bottom => pos(
|
||||
node.position.x + along,
|
||||
node.position.y + node.size.height - inset,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a cardinal bounding side, then map its fraction onto the painted
|
||||
/// contour. Contour distances cannot distinguish the sides of a circle at its
|
||||
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
|
||||
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
|
||||
/// rounds to hundredths: source/API values retain their complete precision.
|
||||
pub fn attachment_from_point(
|
||||
node: &ProjectedNode,
|
||||
cursor: Position,
|
||||
previous: Option<PortAnchorSide>,
|
||||
hysteresis: f32,
|
||||
) -> EdgeAttachment {
|
||||
let candidate = |side| EdgeAttachment {
|
||||
side,
|
||||
offset: projection::port_anchor_offset(node, side, cursor),
|
||||
};
|
||||
let distance = |side| {
|
||||
length(
|
||||
projection::port_anchor_position(node, side, candidate(side).offset),
|
||||
cursor,
|
||||
)
|
||||
};
|
||||
let mut side = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
let hysteresis = hysteresis
|
||||
.max(0.)
|
||||
.min(node.size.width.min(node.size.height) * 0.2);
|
||||
if let Some(old) = previous
|
||||
&& distance(old) <= distance(side) + hysteresis
|
||||
{
|
||||
side = old;
|
||||
}
|
||||
let mut attachment = candidate(side);
|
||||
attachment.offset = (attachment.offset * 100.).round() / 100.;
|
||||
attachment
|
||||
}
|
||||
|
||||
pub(super) fn endpoint(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: EdgeAttachment,
|
||||
) -> Endpoint {
|
||||
let point = attachment_point(project, diagram, node, &attachment);
|
||||
let stub = match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
|
||||
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
|
||||
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
|
||||
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
|
||||
};
|
||||
Endpoint { point, stub }
|
||||
}
|
||||
@@ -93,6 +93,14 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Context::Diagram => {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
|
||||
route.entry("source_attachment".to_owned()).or_insert(Value::Null);
|
||||
route.entry("target_attachment".to_owned()).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Context::Node => {
|
||||
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||
}
|
||||
@@ -244,7 +252,7 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes {
|
||||
if let Some(route) = route.as_object_mut() {
|
||||
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
|
||||
omit_defaults(route, &object(json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,17 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
for attachment in [route.source_attachment, route.target_attachment].into_iter().flatten() {
|
||||
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_attachment_offset",
|
||||
"routes",
|
||||
"Connection attachment offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
Reference in New Issue
Block a user