Implement bounded typed data and identified interface contracts

This commit is contained in:
2026-09-06 03:44:09 +03:00
parent 1230ab8a3e
commit 0b58107097
15 changed files with 3127 additions and 177 deletions
+128 -3
View File
@@ -1,7 +1,7 @@
//! 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, RelationKind, Size};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id },
@@ -466,6 +582,14 @@ fn route_diagram_impl(
// never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else {
continue;
@@ -579,7 +703,7 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-transition must leave and return at distinct anchors.
// 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()
@@ -1107,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange => relation.name.as_str(),
| RelationKind::BoundaryExchange
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",