Files
wordcraft/crates/doc/src/connector.rs
T
nexotiqandGPT-6 cfa59f2c73 cu20we: preserve non-paragraph float origins and document cap contracts
Use the pre-spacing origin only for paragraph-relative box floats. Expand
regression coverage for line anchors and frameless page/margin fallbacks,
and document the cap conversion helpers and synthetic regression contracts.

Merge upstream main through 478fd445 and reconcile the roadmap progress log.
Native CI stages passed; the WASM gate passed on a targeted rerun after a
local sandbox linker adjustment. Optional catalog/font inputs were absent.

Co-Authored-By: GPT-6 <codex@openai.com>
2026-10-23 18:57:51 -05:00

580 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
//! warp, a rounded rectangle's corner, and a connector's ends.
//!
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
//! glued to a connection site of another shape (`a:stCxn` / `a:endCxn`, ECMA-376 §20.1.2.2.36 and
//! §20.1.2.2.13: the shape's drawing id and a site index). When a glued shape moves, [`route`]
//! gives the connector's new frame from its ends.
use serde::{Deserialize, Serialize};
use crate::graphic::PathSeg;
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Square,
Round,
}
impl LineCap {
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sq" => Self::Square,
"rnd" => Self::Round,
_ => Self::Flat,
}
}
/// Return the canonical DrawingML spelling used when saving the outline.
pub fn ooxml(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Square => "sq",
Self::Round => "rnd",
}
}
/// Whether the cap can be omitted from older-compatible serialized shape properties.
pub fn is_flat(&self) -> bool {
*self == Self::Flat
}
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShapeExtra {
/// The drawing id connectors refer to (`wp:docPr/@id`, `wps:cNvPr/@id`); 0 = none. Kept only
/// for shapes a connector is glued to.
#[serde(skip_serializing_if = "is_zero")]
pub id: u32,
/// The text's warp (Text Effects › Transform).
#[serde(skip_serializing_if = "Option::is_none")]
pub warp: Option<TextWarp>,
/// What a connector's start and end are glued to.
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<ConnEnd>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<ConnEnd>,
/// Arrowheads at a line's or connector's start (`a:headEnd`) and end (`a:tailEnd`).
#[serde(skip_serializing_if = "is_false")]
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
pub corner: Option<u32>,
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
/// The outline ends; older saved documents and absent DrawingML caps are flat.
#[serde(skip_serializing_if = "LineCap::is_flat")]
pub cap: LineCap,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn is_false(v: &bool) -> bool {
!*v
}
impl ShapeExtra {
pub fn is_empty(&self) -> bool {
*self == ShapeExtra::default()
}
}
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
pub const MAX_CORNER: u32 = 50_000;
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
pub const DEFAULT_CORNER: f32 = 0.16;
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
/// [`ShapeExtra::corner`]).
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
}
/// One glued end of a connector: the drawing id of the shape and its connection site index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnEnd {
pub id: u32,
pub site: u32,
}
/// Largest connection site index read from a file.
pub const MAX_SITE: u32 = 64;
impl ShapeKind {
/// A connector (straight, elbow or curved): a line that can be glued to shapes.
pub fn is_connector(self) -> bool {
matches!(self, ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector)
}
/// A line or connector: drawn as an open path, never filled.
pub fn is_open(self) -> bool {
self == ShapeKind::Line || self.is_connector()
}
}
/// The connection sites of a `kind` shape `w` × `h`, in its own unrotated frame, by index. The
/// order follows the preset shapes' connection lists (for a rectangle: top, left, bottom, right).
pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let (cx, cy) = (w / 2.0, h / 2.0);
match kind {
ShapeKind::Ellipse => {
// The four sides, and the points of the ellipse at 45° between them.
let (dx, dy) = (cx * std::f32::consts::FRAC_1_SQRT_2, cy * std::f32::consts::FRAC_1_SQRT_2);
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
}
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
// The corners, the legs' midpoints and the hypotenuse's.
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
}
}
/// Site `site` of a `kind` shape with frame `rect` (x, y, w, h) on the page, turned and flipped by
/// `spin` about its centre. `None` for a site the shape doesn't have.
pub fn site_point(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, site: u32) -> Option<(f32, f32)> {
let [x, y, w, h] = rect;
let (sx, sy) = *sites(kind, w, h).get(site as usize)?;
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
let (px, py) = spin.apply(cx, cy, x + sx, y + sy);
(px.is_finite() && py.is_finite()).then_some((px, py))
}
/// The site of a `kind` shape (frame `rect`, `spin`) nearest to page point `at`: its index, point
/// and distance.
pub fn nearest_site(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, at: (f32, f32)) -> Option<(u32, (f32, f32), f32)> {
let n = sites(kind, rect[2], rect[3]).len() as u32;
(0..n)
.filter_map(|i| site_point(kind, rect, spin, i).map(|p| (i, p, ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt())))
.min_by(|a, b| a.2.total_cmp(&b.2))
}
/// A connector's frame: top-left corner, size and flips.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Frame {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub flip_h: bool,
pub flip_v: bool,
}
/// The frame of a connector from `start` to `end`.
pub fn route(start: (f32, f32), end: (f32, f32)) -> Frame {
let f = wordcraft_geom::finite;
let (x0, y0, x1, y1) = (f(start.0), f(start.1), f(end.0), f(end.1));
Frame { x: x0.min(x1), y: y0.min(y1), w: (x1 - x0).abs(), h: (y1 - y0).abs(), flip_h: x1 < x0, flip_v: y1 < y0 }
}
/// A connector's start and end from its frame (the inverse of [`route`]).
pub fn ends(x: f32, y: f32, w: f32, h: f32, flip_h: bool, flip_v: bool) -> ((f32, f32), (f32, f32)) {
let (xa, xb) = if flip_h { (x + w, x) } else { (x, x + w) };
let (ya, yb) = if flip_v { (y + h, y) } else { (y, y + h) };
((xa, ya), (xb, yb))
}
/// The path of a connector `kind` in a `w` × `h` frame at the origin, from its top-left to its
/// bottom-right corner (flips are the frame's turn): straight, an elbow through the middle (out
/// horizontally, across, in horizontally), or an S-curve. `None` for other kinds.
pub fn connector_segs(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<PathSeg>> {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let mx = w / 2.0;
Some(match kind {
ShapeKind::StraightConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(w, h)],
ShapeKind::ElbowConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(mx, 0.0), PathSeg::Line(mx, h), PathSeg::Line(w, h)],
ShapeKind::CurvedConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Cubic(mx, 0.0, mx, h, w, h)],
_ => return None,
})
}
/// The direction (unit vector) a line or connector `kind` arrives at its end (`at_end`) or
/// leaves its start, in its unflipped `w` × `h` frame: where an arrowhead points.
pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32) {
let (dx, dy) = match kind {
ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {
if w.abs() > 0.01 || h.abs() < 0.01 {
(1.0, 0.0)
} else {
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
let (ux, uy) = if len > 1e-6 && len.is_finite() { (dx / len, dy / len) } else { (1.0, 0.0) };
if at_end { (ux, uy) } else { (-ux, -uy) }
}
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
/// fallback are unchanged.
pub fn reroute_group(group: &mut InlineObject) -> bool {
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
let fin = wordcraft_geom::finite;
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
.iter()
.filter_map(|c| match &c.obj {
InlineObject::Shape { kind, w, h, float, extra, .. } if extra.id != 0 => Some((extra.id, *kind, [c.x, c.y, *w, *h], float.spin())),
_ => None,
})
.collect();
if shapes.is_empty() {
return false;
}
let find = |e: Option<ConnEnd>| {
let e = e?;
let (_, kind, rect, spin) = shapes.iter().find(|s| s.0 == e.id)?;
site_point(*kind, *rect, *spin, e.site)
};
let mut moved = false;
for c in children.iter_mut() {
let InlineObject::Shape { kind, w, h, float, extra, .. } = &mut c.obj else { continue };
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
continue;
}
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
(Some(s), Some(e)) => (s, e, false),
(s, e) => {
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
continue;
}
// Rendering scales the rectangle before spinning it. Undo only the group scale
// so route can rebuild an unrotated child frame without moving fallback ends.
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
let spin = float.spin();
let s0 = spin.apply(cx, cy, x, y);
let e0 = spin.apply(cx, cy, x + cw, y + ch);
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
}
};
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
continue;
}
let f = route(s, e);
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
continue;
}
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
continue;
}
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
continue;
}
c.x = f.x;
c.y = f.y;
*w = f.w;
*h = f.h;
float.rot = 0.0;
float.flip_h = f.flip_h;
float.flip_v = f.flip_v;
moved = true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::{Float, GroupChild};
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
#[test]
fn older_shape_json_defaults_to_flat_caps() {
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
assert_eq!(extra.cap, LineCap::Flat);
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
for cap in [LineCap::Square, LineCap::Round] {
let extra = ShapeExtra { cap, ..Default::default() };
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
}
}
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
kind,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Float::default(),
story: None,
freeform: None,
effects: Default::default(),
extra,
}
}
fn group(children: Vec<GroupChild>) -> InlineObject {
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
}
#[test]
fn route_and_ends_are_inverse() {
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
let f = route(s, e);
assert_eq!(ends(f.x, f.y, f.w, f.h, f.flip_h, f.flip_v), (s, e));
}
}
#[test]
fn corner_radius_follows_the_shorter_side() {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = rot;
float.flip_h = flip_h;
float.flip_v = flip_v;
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
// clockwise in y-down group space. Do not use the routing transform
// to compute the expectation.
let sign = if free_start { -1.0 } else { 1.0 };
let dx = sign * if flip_h { -20.0 } else { 20.0 };
let dy = sign * if flip_v { -10.0 } else { 10.0 };
let (sin, cos) = rot.to_radians().sin_cos();
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
let kids = vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
},
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (free, attached) = if free_start { (start, end) } else { (end, start) };
assert!(
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
);
assert_eq!(attached, (140.0, 210.0));
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group), "rerouting must settle");
}
}
}
}
}
}
#[test]
fn rerouting_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let kids = vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
(*w, *h) = (400.0, 150.0);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.flip_h = free_start;
let mut group = group(vec![
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 3000.0, y: 10.0, obj },
]);
let InlineObject::Group { w, .. } = &mut group else { panic!() };
*w = 400.0;
let visual_free = |group: &InlineObject| {
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
let ([x, y, w, h], obj) = rects[1];
let spin = obj.frame().unwrap().2.spin();
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
};
let before = visual_free(&group);
assert_eq!(before, (6080.0, 10.0));
let moved = reroute_group(&mut group);
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
}
}
#[test]
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
for dims in [
[0.0, 300.0, 200.0, 300.0],
[200.0, 0.0, 200.0, 300.0],
[-200.0, 300.0, 200.0, 300.0],
[f32::NAN, 300.0, 200.0, 300.0],
[200.0, f32::INFINITY, 200.0, 300.0],
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
[200.0, 300.0, 0.0, 0.0],
[200.0, 300.0, f32::NAN, f32::INFINITY],
[200.0, 300.0, -200.0, -300.0],
] {
let mut obj =
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
let moved = reroute_group(&mut group);
// group_rects treats invalid child extents as unit scale; display scales that
// cannot be inverted (or whose inverse overflows) must not change the connector.
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
}
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
let b = ShapeExtra { id: 9, ..Default::default() };
let link = ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), end: Some(ConnEnd { id: 9, site: 2 }), ..Default::default() };
let mut kids = vec![
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 20.0, 10.0, a) },
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
];
// Fully resolved endpoints must still come from their sites, regardless of the
// connector's old rotation and flips.
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
float.rot = 90.0;
float.flip_h = true;
float.flip_v = true;
let mut group = group(kids);
assert!(reroute_group(&mut group));
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
assert!(!reroute_group(&mut group), "settled");
// A fully resolved connector does not need an invertible group scale.
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
let mut scaled = group.clone();
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
(*w, *h) = (width, height);
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
float.rot = 90.0;
assert!(reroute_group(&mut scaled));
let InlineObject::Group { children, .. } = &scaled else { panic!() };
let c = &children[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
assert!(!reroute_group(&mut scaled));
}
// A connection to a missing id or site keeps that end where it was.
let InlineObject::Group { children, .. } = &mut group else { panic!() };
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
extra.end = Some(ConnEnd { id: 404, site: 0 });
extra.start = Some(ConnEnd { id: 7, site: 99 });
}
assert!(!reroute_group(&mut group));
}
}