use std::collections::BTreeMap;
use zenith_core::{ConnectorNode, Diagnostic, FontProvider, ResolvedToken, Style, TextNode};
use zenith_layout::RustybuzzEngine;
use crate::ir::{Paint, SceneCommand, StrokeAlign};
use super::super::RenderCtx;
use super::super::anchor::AnchorMap;
use super::super::chain::ChainAssignments;
use super::super::paint::resolve_property_color;
use super::super::style_prop;
use super::super::text::{
MeasureEnv, TextCompileEnv, compile_text, empty_md_blocks, measure_text_wrapped_height,
resolve_text_families,
};
use super::super::util::{px_prop, resolve_property_dimension_px, rotation_degrees};
use super::poly::flat_points_centroid_center;
use super::routing;
const ROUTE_MARGIN: f64 = 8.0;
#[derive(Clone, Copy)]
pub(in crate::compile) struct ConnectorEnv<'a> {
pub(in crate::compile) resolved: &'a BTreeMap<String, ResolvedToken>,
pub(in crate::compile) style_map: &'a BTreeMap<&'a str, &'a Style>,
pub(in crate::compile) fonts: &'a dyn FontProvider,
pub(in crate::compile) engine: &'a RustybuzzEngine,
pub(in crate::compile) chains: &'a ChainAssignments,
pub(in crate::compile) footnote_markers: &'a BTreeMap<String, String>,
pub(in crate::compile) node_boxes: &'a BTreeMap<String, (f64, f64, f64, f64)>,
pub(in crate::compile) anchors: &'a AnchorMap,
pub(in crate::compile) ctx: RenderCtx,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AnchorSide {
Horizontal,
Vertical,
}
fn resolve_anchor(
boxr: (f64, f64, f64, f64),
anchor: &str,
toward: (f64, f64),
) -> ((f64, f64), AnchorSide) {
let (x, y, w, h) = boxr;
let cx = x + w / 2.0;
let cy = y + h / 2.0;
if let Some(resolved) = grid_anchor(anchor, boxr) {
return resolved;
}
let dx = toward.0 - cx;
let dy = toward.1 - cy;
if dx.abs() >= dy.abs() {
let pt = if dx >= 0.0 { (x + w, cy) } else { (x, cy) };
(pt, AnchorSide::Horizontal)
} else if dy >= 0.0 {
((cx, y + h), AnchorSide::Vertical)
} else {
((cx, y), AnchorSide::Vertical)
}
}
fn grid_anchor(anchor: &str, boxr: (f64, f64, f64, f64)) -> Option<((f64, f64), AnchorSide)> {
let (x, y, w, h) = boxr;
let (mut top, mut bottom, mut left, mut right, mut center, mut recognized) =
(false, false, false, false, false, false);
for part in anchor.split('-') {
match part {
"top" => top = true,
"bottom" => bottom = true,
"left" => left = true,
"right" => right = true,
"center" | "centre" | "mid" | "middle" => center = true,
_ => continue,
}
recognized = true;
}
if !recognized {
return None;
}
let px = if left {
x
} else if right {
x + w
} else {
x + w / 2.0
};
let py = if top {
y
} else if bottom {
y + h
} else {
y + h / 2.0
};
let vertical_only = (top || bottom) && !(left || right);
let side = if vertical_only {
AnchorSide::Vertical
} else {
AnchorSide::Horizontal
};
let _ = center; Some(((px, py), side))
}
fn orthogonal_route(f: (f64, f64), fs: AnchorSide, t: (f64, f64), ts: AnchorSide) -> Vec<f64> {
match (fs, ts) {
(AnchorSide::Horizontal, AnchorSide::Horizontal) => {
let mx = (f.0 + t.0) / 2.0;
vec![f.0, f.1, mx, f.1, mx, t.1, t.0, t.1]
}
(AnchorSide::Vertical, AnchorSide::Vertical) => {
let my = (f.1 + t.1) / 2.0;
vec![f.0, f.1, f.0, my, t.0, my, t.0, t.1]
}
(AnchorSide::Horizontal, AnchorSide::Vertical) => {
vec![f.0, f.1, t.0, f.1, t.0, t.1]
}
(AnchorSide::Vertical, AnchorSide::Horizontal) => {
vec![f.0, f.1, f.0, t.1, t.0, t.1]
}
}
}
fn outward_dir(side: AnchorSide, pt: (f64, f64), boxr: (f64, f64, f64, f64)) -> (f64, f64) {
let cx = boxr.0 + boxr.2 / 2.0;
let cy = boxr.1 + boxr.3 / 2.0;
match side {
AnchorSide::Horizontal => {
let sign = if pt.0 - cx >= 0.0 { 1.0 } else { -1.0 };
(sign, 0.0)
}
AnchorSide::Vertical => {
let sign = if pt.1 - cy >= 0.0 { 1.0 } else { -1.0 };
(0.0, sign)
}
}
}
const LOOP_DEPTH: f64 = 28.0;
const LOOP_HALF_MAX: f64 = 25.0;
fn loop_side(anchor: Option<&str>) -> &'static str {
match anchor {
Some(a) if a.contains("bottom") => "bottom",
Some(a) if a.contains("left") => "left",
Some(a) if a.contains("right") => "right",
_ => "top",
}
}
fn self_loop_path(boxr: (f64, f64, f64, f64), side: &str) -> Vec<f64> {
let (x, y, w, h) = boxr;
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let d = LOOP_DEPTH;
match side {
"bottom" => {
let half = (w * 0.3).min(LOOP_HALF_MAX);
let yb = y + h;
vec![
cx - half,
yb,
cx - half,
yb + d,
cx + half,
yb + d,
cx + half,
yb,
]
}
"left" => {
let half = (h * 0.3).min(LOOP_HALF_MAX);
vec![
x,
cy - half,
x - d,
cy - half,
x - d,
cy + half,
x,
cy + half,
]
}
"right" => {
let half = (h * 0.3).min(LOOP_HALF_MAX);
let xr = x + w;
vec![
xr,
cy - half,
xr + d,
cy - half,
xr + d,
cy + half,
xr,
cy + half,
]
}
_ => {
let half = (w * 0.3).min(LOOP_HALF_MAX);
vec![
cx - half,
y,
cx - half,
y - d,
cx + half,
y - d,
cx + half,
y,
]
}
}
}
fn point_at(pts: &[f64], i: usize) -> Option<(f64, f64)> {
let x = pts.get(i * 2)?;
let y = pts.get(i * 2 + 1)?;
Some((*x, *y))
}
fn polyline_midpoint(pts: &[f64]) -> Option<(f64, f64)> {
let n = pts.len() / 2;
if n < 2 {
return None;
}
let mut total = 0.0_f64;
for i in 0..n.saturating_sub(1) {
let (x0, y0) = (pts[i * 2], pts[i * 2 + 1]);
let (x1, y1) = (pts[i * 2 + 2], pts[i * 2 + 3]);
let dx = x1 - x0;
let dy = y1 - y0;
total += (dx * dx + dy * dy).sqrt();
}
let half = total / 2.0;
let mut walked = 0.0_f64;
for i in 0..n.saturating_sub(1) {
let (x0, y0) = (pts[i * 2], pts[i * 2 + 1]);
let (x1, y1) = (pts[i * 2 + 2], pts[i * 2 + 3]);
let dx = x1 - x0;
let dy = y1 - y0;
let seg_len = (dx * dx + dy * dy).sqrt();
if walked + seg_len >= half {
let t = if seg_len < 1e-9 {
0.0
} else {
(half - walked) / seg_len
};
return Some((x0 + t * dx, y0 + t * dy));
}
walked += seg_len;
}
let last = n - 1;
Some((pts[last * 2], pts[last * 2 + 1]))
}
fn emit_connector_label(
connector: &ConnectorNode,
flat_points: &[f64],
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
env: ConnectorEnv,
) {
if connector.spans.is_empty() {
return;
}
let Some((mx, my)) = polyline_midpoint(flat_points) else {
return;
};
let ConnectorEnv {
resolved,
style_map,
fonts,
engine,
chains,
footnote_markers,
node_boxes,
anchors,
ctx,
} = env;
const LABEL_W: f64 = 120.0;
const LABEL_H: f64 = 40.0;
let lx = mx - LABEL_W / 2.0;
let ly = my - LABEL_H / 2.0;
let mut synth = TextNode {
id: format!("{}/label", connector.id),
name: None,
role: None,
x: Some(px_prop(lx)),
y: Some(px_prop(ly)),
w: Some(px_prop(LABEL_W)),
h: Some(px_prop(LABEL_H)),
align: Some("center".to_owned()),
v_align: None,
direction: None,
overflow: None,
overflow_wrap: None,
style: connector.text_style.clone(),
fill: None,
stroke: None,
stroke_width: None,
contrast_bg: None,
font_family: None,
font_size: None,
font_size_min: None,
font_weight: None,
shadow: None,
filter: None,
mask: None,
blend_mode: None,
blur: None,
opacity: None,
visible: None,
locked: None,
selectable: None,
rotate: None,
chain: None,
drop_cap_lines: None,
hyphenate: None,
widow_orphan: None,
tab_leader: None,
text_exclusion: None,
padding_left: None,
text_indent: None,
content_format: None,
src: None,
bullet: None,
bullet_gap: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
spans: connector.spans.clone(),
block_styles: Vec::new(),
source_span: connector.source_span,
unknown_props: BTreeMap::new(),
};
let families = resolve_text_families(&synth, resolved, style_map, fonts, diagnostics);
let wrapped_h = measure_text_wrapped_height(
&synth,
LABEL_W,
&families,
MeasureEnv {
resolved,
style_map,
fonts,
engine,
},
diagnostics,
)
.unwrap_or(0.0);
let v_offset = ((LABEL_H - wrapped_h) / 2.0).max(0.0);
synth.y = Some(px_prop(ly + v_offset));
let label_ctx = RenderCtx {
dx: 0.0,
dy: 0.0,
..ctx
};
let _ = compile_text(
&synth,
TextCompileEnv {
resolved,
style_map,
fonts,
engine,
chains,
footnote_markers,
node_boxes,
anchors,
md_blocks: empty_md_blocks(),
page_block_styles: &[],
doc_block_styles: &[],
},
commands,
diagnostics,
label_ctx,
);
}
pub(in crate::compile) fn compile_connector(
connector: &ConnectorNode,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
env: ConnectorEnv,
) {
let ConnectorEnv {
resolved,
style_map,
node_boxes,
ctx,
..
} = env;
if connector.visible == Some(false) {
return;
}
let (Some(from_id), Some(to_id)) = (connector.from.as_deref(), connector.to.as_deref()) else {
return;
};
let (Some(from_box), Some(to_box)) = (node_boxes.get(from_id), node_boxes.get(to_id)) else {
return;
};
let from_box = *from_box;
let to_box = *to_box;
let from_center = (from_box.0 + from_box.2 / 2.0, from_box.1 + from_box.3 / 2.0);
let to_center = (to_box.0 + to_box.2 / 2.0, to_box.1 + to_box.3 / 2.0);
let from_anchor = connector.from_anchor.as_deref().unwrap_or("auto");
let to_anchor = connector.to_anchor.as_deref().unwrap_or("auto");
let (f_pt, f_side) = resolve_anchor(from_box, from_anchor, to_center);
let (t_pt, t_side) = resolve_anchor(to_box, to_anchor, from_center);
let flat_points = if from_id == to_id {
let side = loop_side(
connector
.from_anchor
.as_deref()
.or(connector.to_anchor.as_deref()),
);
self_loop_path(from_box, side)
} else {
match connector.route.as_deref() {
Some("orthogonal") => orthogonal_route(f_pt, f_side, t_pt, t_side),
Some("avoid") => {
let obstacles: Vec<(f64, f64, f64, f64)> = node_boxes
.iter()
.filter(|(id, _)| id.as_str() != from_id && id.as_str() != to_id)
.map(|(_, b)| *b)
.collect();
let f_out = outward_dir(f_side, f_pt, from_box);
let t_out = outward_dir(t_side, t_pt, to_box);
routing::route_orthogonal_avoiding(
f_pt,
f_out,
t_pt,
t_out,
&obstacles,
ROUTE_MARGIN,
)
.unwrap_or_else(|| orthogonal_route(f_pt, f_side, t_pt, t_side))
}
_ => vec![f_pt.0, f_pt.1, t_pt.0, t_pt.1],
}
};
let node_opacity = connector.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
let stroke_prop = connector
.stroke
.as_ref()
.or_else(|| style_prop(&connector.style, style_map, "stroke"));
let Some(stroke_prop) = stroke_prop else {
return;
};
let Some(mut color) = resolve_property_color(stroke_prop, resolved, diagnostics, &connector.id)
else {
return;
};
color.a = (color.a as f64 * node_opacity * ctx.opacity).round() as u8;
let sw = connector
.stroke_width
.clone()
.or_else(|| style_prop(&connector.style, style_map, "stroke-width").cloned());
let stroke_width = resolve_property_dimension_px(sw.as_ref(), resolved, 1.0);
let rot = rotation_degrees(connector.rotate.as_ref());
if let Some(angle) = rot {
let (cx, cy) = flat_points_centroid_center(&flat_points);
commands.push(SceneCommand::PushTransform {
angle_deg: angle,
cx,
cy,
});
}
let n = flat_points.len() / 2;
let end_tip = point_at(&flat_points, n.saturating_sub(1));
let end_from = point_at(&flat_points, n.saturating_sub(2));
let start_tip = point_at(&flat_points, 0);
let start_from = point_at(&flat_points, 1);
commands.push(SceneCommand::StrokePolyline {
points: flat_points.clone(),
color,
stroke_width,
closed: false,
align: StrokeAlign::Center,
fill_even_odd: false,
});
{
let mut emit_head = |tip, from_pt| {
if let Some(points) = arrowhead_points(tip, from_pt, stroke_width) {
commands.push(SceneCommand::FillPolygon {
points,
paint: Paint::solid(color),
even_odd: false,
});
}
};
if connector.marker_end.as_deref() == Some("arrow")
&& let (Some(tip), Some(from_pt)) = (end_tip, end_from)
{
emit_head(tip, from_pt);
}
if connector.marker_start.as_deref() == Some("arrow")
&& let (Some(tip), Some(from_pt)) = (start_tip, start_from)
{
emit_head(tip, from_pt);
}
}
if rot.is_some() {
commands.push(SceneCommand::PopTransform);
}
emit_connector_label(connector, &flat_points, commands, diagnostics, env);
}
fn arrowhead_points(tip: (f64, f64), from_pt: (f64, f64), stroke_width: f64) -> Option<Vec<f64>> {
let vx = tip.0 - from_pt.0;
let vy = tip.1 - from_pt.1;
let len = (vx * vx + vy * vy).sqrt();
if len < 1e-6 {
return None;
}
let (ux, uy) = (vx / len, vy / len);
let (px, py) = (-uy, ux);
let head_len = (stroke_width * 3.5).max(7.0);
let half_w = (stroke_width * 2.0).max(4.0);
let base_cx = tip.0 - ux * head_len;
let base_cy = tip.1 - uy * head_len;
let left_x = base_cx + px * half_w;
let left_y = base_cy + py * half_w;
let right_x = base_cx - px * half_w;
let right_y = base_cy - py * half_w;
Some(vec![tip.0, tip.1, left_x, left_y, right_x, right_y])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loop_side_parses_edges_and_defaults_to_top() {
assert_eq!(loop_side(Some("bottom-center")), "bottom");
assert_eq!(loop_side(Some("center-left")), "left");
assert_eq!(loop_side(Some("right")), "right");
assert_eq!(loop_side(Some("top")), "top");
assert_eq!(loop_side(Some("auto")), "top");
assert_eq!(loop_side(None), "top");
}
#[test]
fn self_loop_top_bulges_above_the_box() {
let pts = self_loop_path((60.0, 110.0, 120.0, 60.0), "top");
assert_eq!(pts.len(), 8);
let half = (120.0_f64 * 0.3).min(LOOP_HALF_MAX);
assert_eq!(
pts,
vec![
120.0 - half,
110.0,
120.0 - half,
110.0 - LOOP_DEPTH,
120.0 + half,
110.0 - LOOP_DEPTH,
120.0 + half,
110.0,
]
);
assert!(pts[3] < 110.0 && pts[5] < 110.0);
}
#[test]
fn self_loop_right_bulges_past_the_right_edge() {
let pts = self_loop_path((250.0, 110.0, 120.0, 60.0), "right");
assert_eq!(pts.len(), 8);
assert_eq!(pts[0], 370.0);
assert_eq!(pts[6], 370.0);
assert!(pts[2] > 370.0 && pts[4] > 370.0);
}
#[test]
fn self_loop_is_deterministic() {
let a = self_loop_path((10.0, 20.0, 80.0, 40.0), "bottom");
let b = self_loop_path((10.0, 20.0, 80.0, 40.0), "bottom");
assert_eq!(a, b);
}
}