use crate::ir::SceneCommand;
const JUMP_R: f64 = 5.0;
const ARC_SEGMENTS: usize = 8;
const EPS: f64 = 1e-9;
pub(in crate::compile) fn record_connector_stroke(
commands: &[SceneCommand],
start: usize,
out: &mut Vec<usize>,
) {
let range = match commands.get(start..) {
Some(r) => r,
None => return,
};
for (offset, cmd) in range.iter().enumerate() {
if matches!(cmd, SceneCommand::StrokePolyline { .. }) {
out.push(start + offset);
return;
}
}
}
#[derive(Clone, Copy)]
enum Transform {
Identity,
Rotate { angle_deg: f64, cx: f64, cy: f64 },
}
impl Transform {
fn to_page(self, p: (f64, f64)) -> (f64, f64) {
match self {
Transform::Identity => p,
Transform::Rotate { angle_deg, cx, cy } => rotate_pt(p, angle_deg, (cx, cy)),
}
}
fn to_local(self, p: (f64, f64)) -> (f64, f64) {
match self {
Transform::Identity => p,
Transform::Rotate { angle_deg, cx, cy } => rotate_pt(p, -angle_deg, (cx, cy)),
}
}
}
fn rotate_pt(p: (f64, f64), angle_deg: f64, pivot: (f64, f64)) -> (f64, f64) {
let (px, py) = p;
let (cx, cy) = pivot;
let rad = angle_deg.to_radians();
let (s, c) = (rad.sin(), rad.cos());
let dx = px - cx;
let dy = py - cy;
(cx + dx * c - dy * s, cy + dx * s + dy * c)
}
fn active_transform_at(commands: &[SceneCommand], idx: usize) -> Option<Transform> {
let mut stack: Vec<Transform> = Vec::new();
let prefix = match commands.get(..idx) {
Some(p) => p,
None => return Some(Transform::Identity),
};
for cmd in prefix {
if let SceneCommand::PushTransform { angle_deg, cx, cy } = cmd {
stack.push(Transform::Rotate {
angle_deg: *angle_deg,
cx: *cx,
cy: *cy,
});
} else if matches!(cmd, SceneCommand::PopTransform) {
stack.pop();
}
}
match stack.as_slice() {
[] => Some(Transform::Identity),
[single] => Some(*single),
_ => None,
}
}
#[derive(Clone, Copy)]
struct Hop {
seg: usize,
px: f64,
py: f64,
dist_from_start: f64,
}
pub(in crate::compile) fn apply_line_jumps(
commands: &mut Vec<SceneCommand>,
connector_strokes: &[usize],
mode: &str,
) {
if mode != "arc" && mode != "gap" {
return;
}
let mut snapshots: Vec<Snapshot> = Vec::with_capacity(connector_strokes.len());
for &idx in connector_strokes {
let Some(transform) = active_transform_at(commands, idx) else {
continue;
};
if let Some(SceneCommand::StrokePolyline { points, .. }) = commands.get(idx) {
let on_page = map_points(points, |p| transform.to_page(p));
snapshots.push(Snapshot {
idx,
transform,
on_page,
});
}
}
let mut hops_per_connector: Vec<Vec<Hop>> = vec![Vec::new(); snapshots.len()];
for (i, a) in snapshots.iter().enumerate() {
for (j, b) in snapshots.iter().enumerate().skip(i + 1) {
collect_pair_hops(&a.on_page, &b.on_page, i, j, &mut hops_per_connector);
}
}
if mode == "arc" {
apply_arc(commands, &snapshots, &hops_per_connector);
} else {
apply_gap(commands, &snapshots, &hops_per_connector);
}
}
struct Snapshot {
idx: usize,
transform: Transform,
on_page: Vec<f64>,
}
fn map_points(pts: &[f64], f: impl Fn((f64, f64)) -> (f64, f64)) -> Vec<f64> {
let mut out = Vec::with_capacity(pts.len());
for pair in pts.chunks_exact(2) {
if let (Some(&x), Some(&y)) = (pair.first(), pair.get(1)) {
let (nx, ny) = f((x, y));
out.push(nx);
out.push(ny);
}
}
out
}
fn collect_pair_hops(
a_pts: &[f64],
b_pts: &[f64],
i: usize,
j: usize,
hops_per_connector: &mut [Vec<Hop>],
) {
let a_segs = a_pts.len() / 2;
let b_segs = b_pts.len() / 2;
let mut sa = 0;
while sa + 1 < a_segs {
let Some(a) = segment(a_pts, sa) else {
sa += 1;
continue;
};
let mut sb = 0;
while sb + 1 < b_segs {
let Some(b) = segment(b_pts, sb) else {
sb += 1;
continue;
};
if let Some((px, py)) = proper_intersection(a, b) {
let hop_is_a = is_horizontal(a) && is_vertical(b);
if hop_is_a {
if let Some(list) = hops_per_connector.get_mut(i) {
let d = dist_from_start(a, px, py);
list.push(Hop {
seg: sa,
px,
py,
dist_from_start: d,
});
}
} else if let Some(list) = hops_per_connector.get_mut(j) {
let d = dist_from_start(b, px, py);
list.push(Hop {
seg: sb,
px,
py,
dist_from_start: d,
});
}
}
sb += 1;
}
sa += 1;
}
}
type Seg = ((f64, f64), (f64, f64));
fn segment(pts: &[f64], s: usize) -> Option<Seg> {
let x0 = *pts.get(2 * s)?;
let y0 = *pts.get(2 * s + 1)?;
let x1 = *pts.get(2 * s + 2)?;
let y1 = *pts.get(2 * s + 3)?;
Some(((x0, y0), (x1, y1)))
}
fn is_horizontal(seg: Seg) -> bool {
let ((_, y0), (_, y1)) = seg;
(y1 - y0).abs() < EPS
}
fn is_vertical(seg: Seg) -> bool {
let ((x0, _), (x1, _)) = seg;
(x1 - x0).abs() < EPS
}
fn dist_from_start(seg: Seg, px: f64, py: f64) -> f64 {
let ((x0, y0), _) = seg;
let dx = px - x0;
let dy = py - y0;
(dx * dx + dy * dy).sqrt()
}
fn proper_intersection(a: Seg, b: Seg) -> Option<(f64, f64)> {
let ((ax0, ay0), (ax1, ay1)) = a;
let ((bx0, by0), (bx1, by1)) = b;
let rx = ax1 - ax0;
let ry = ay1 - ay0;
let sx = bx1 - bx0;
let sy = by1 - by0;
let denom = rx * sy - ry * sx;
if denom.abs() < EPS {
return None;
}
let qpx = bx0 - ax0;
let qpy = by0 - ay0;
let t = (qpx * sy - qpy * sx) / denom;
let u = (qpx * ry - qpy * rx) / denom;
if t > EPS && t < 1.0 - EPS && u > EPS && u < 1.0 - EPS {
Some((ax0 + t * rx, ay0 + t * ry))
} else {
None
}
}
fn sort_hops(hops: &mut [Hop]) {
hops.sort_by(|a, b| {
a.seg
.cmp(&b.seg)
.then_with(|| a.dist_from_start.total_cmp(&b.dist_from_start))
});
}
fn apply_arc(
commands: &mut [SceneCommand],
snapshots: &[Snapshot],
hops_per_connector: &[Vec<Hop>],
) {
for (pos, snap) in snapshots.iter().enumerate() {
let Some(hops) = hops_per_connector.get(pos) else {
continue;
};
if hops.is_empty() {
continue;
}
let mut ordered = hops.to_vec();
sort_hops(&mut ordered);
let on_page_pts = rebuild_points_with_bumps(&snap.on_page, &ordered);
let new_pts = map_points(&on_page_pts, |p| snap.transform.to_local(p));
if let Some(SceneCommand::StrokePolyline { points, .. }) = commands.get_mut(snap.idx) {
*points = new_pts;
}
}
}
fn rebuild_points_with_bumps(base_pts: &[f64], ordered_hops: &[Hop]) -> Vec<f64> {
let n = base_pts.len() / 2;
let mut out: Vec<f64> = Vec::with_capacity(base_pts.len());
if n == 0 {
return out;
}
if let (Some(&x0), Some(&y0)) = (base_pts.first(), base_pts.get(1)) {
out.push(x0);
out.push(y0);
}
let mut s = 0;
while s + 1 < n {
if let Some(seg) = segment(base_pts, s) {
for hop in ordered_hops.iter().filter(|h| h.seg == s) {
push_bump(&mut out, seg, hop.px, hop.py);
}
}
if let (Some(&x), Some(&y)) = (base_pts.get(2 * (s + 1)), base_pts.get(2 * (s + 1) + 1)) {
out.push(x);
out.push(y);
}
s += 1;
}
out
}
fn push_bump(out: &mut Vec<f64>, seg: Seg, px: f64, py: f64) {
let ((x0, y0), (x1, y1)) = seg;
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
if len < EPS {
return;
}
let ux = dx / len;
let uy = dy / len;
let (nx, ny) = if is_horizontal(seg) {
(0.0, -1.0)
} else if is_vertical(seg) {
(-1.0, 0.0)
} else {
(0.0, -1.0)
};
let pi = std::f64::consts::PI;
let steps = ARC_SEGMENTS;
out.push(px - JUMP_R * ux);
out.push(py - JUMP_R * uy);
let mut k = 1;
while k < steps {
let frac = k as f64 / steps as f64;
let theta = pi * (1.0 - frac); let along = JUMP_R * theta.cos();
let out_dist = JUMP_R * theta.sin();
let ax = px + along * ux + out_dist * nx;
let ay = py + along * uy + out_dist * ny;
out.push(ax);
out.push(ay);
k += 1;
}
out.push(px + JUMP_R * ux);
out.push(py + JUMP_R * uy);
}
fn apply_gap(
commands: &mut Vec<SceneCommand>,
snapshots: &[Snapshot],
hops_per_connector: &[Vec<Hop>],
) {
use std::collections::BTreeMap;
let mut split_at: BTreeMap<usize, (Transform, Vec<f64>, Vec<Hop>)> = BTreeMap::new();
for (pos, snap) in snapshots.iter().enumerate() {
if let Some(hops) = hops_per_connector.get(pos)
&& !hops.is_empty()
{
let mut ordered = hops.to_vec();
sort_hops(&mut ordered);
split_at.insert(snap.idx, (snap.transform, snap.on_page.clone(), ordered));
}
}
if split_at.is_empty() {
return;
}
let mut new_cmds: Vec<SceneCommand> = Vec::with_capacity(commands.len());
for (idx, cmd) in commands.iter().enumerate() {
match split_at.get(&idx) {
Some((transform, on_page, hops)) => {
if let SceneCommand::StrokePolyline {
color,
stroke_width,
closed,
align,
fill_even_odd,
..
} = cmd
{
for piece in split_polyline(on_page, hops) {
let local = map_points(&piece, |p| transform.to_local(p));
new_cmds.push(SceneCommand::StrokePolyline {
points: local,
color: *color,
stroke_width: *stroke_width,
closed: *closed,
align: *align,
fill_even_odd: *fill_even_odd,
});
}
} else {
new_cmds.push(cmd.clone());
}
}
None => new_cmds.push(cmd.clone()),
}
}
*commands = new_cmds;
}
fn split_polyline(base_pts: &[f64], ordered_hops: &[Hop]) -> Vec<Vec<f64>> {
let n = base_pts.len() / 2;
let mut pieces: Vec<Vec<f64>> = Vec::new();
if n == 0 {
return pieces;
}
let mut current: Vec<f64> = Vec::new();
if let (Some(&x0), Some(&y0)) = (base_pts.first(), base_pts.get(1)) {
current.push(x0);
current.push(y0);
}
let mut s = 0;
while s + 1 < n {
if let Some(seg) = segment(base_pts, s) {
let ((x0, y0), (x1, y1)) = seg;
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
for hop in ordered_hops.iter().filter(|h| h.seg == s) {
if len < EPS {
continue;
}
let ux = dx / len;
let uy = dy / len;
current.push(hop.px - JUMP_R * ux);
current.push(hop.py - JUMP_R * uy);
pieces.push(std::mem::take(&mut current));
current.push(hop.px + JUMP_R * ux);
current.push(hop.py + JUMP_R * uy);
}
}
if let (Some(&x), Some(&y)) = (base_pts.get(2 * (s + 1)), base_pts.get(2 * (s + 1) + 1)) {
current.push(x);
current.push(y);
}
s += 1;
}
if current.len() >= 4 {
pieces.push(current);
} else if !current.is_empty() && pieces.is_empty() {
pieces.push(current);
}
pieces
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Color, StrokeAlign};
fn stroke(points: Vec<f64>) -> SceneCommand {
SceneCommand::StrokePolyline {
points,
color: Color::srgb(0, 0, 0, 255),
stroke_width: 2.0,
closed: false,
align: StrokeAlign::Center,
fill_even_odd: false,
}
}
fn polyline_points(cmd: &SceneCommand) -> Vec<f64> {
match cmd {
SceneCommand::StrokePolyline { points, .. } => points.clone(),
_ => panic!("expected StrokePolyline"),
}
}
fn count_strokes(cmds: &[SceneCommand]) -> usize {
cmds.iter()
.filter(|c| matches!(c, SceneCommand::StrokePolyline { .. }))
.count()
}
#[test]
fn arc_horizontal_hops_over_vertical() {
let mut cmds = vec![
stroke(vec![0.0, 50.0, 100.0, 50.0]),
stroke(vec![50.0, 0.0, 50.0, 100.0]),
];
let before_h = polyline_points(&cmds[0]);
let before_v = polyline_points(&cmds[1]);
apply_line_jumps(&mut cmds, &[0, 1], "arc");
let after_h = polyline_points(&cmds[0]);
let after_v = polyline_points(&cmds[1]);
assert!(
after_h.len() > before_h.len(),
"horizontal connector should gain bump points: {after_h:?}"
);
assert_eq!(after_v, before_v, "vertical connector must be unchanged");
let min_y = after_h
.chunks_exact(2)
.map(|p| p[1])
.fold(f64::INFINITY, f64::min);
assert!(min_y < 50.0, "bump must dip above the line (smaller y)");
}
#[test]
fn gap_horizontal_splits() {
let mut cmds = vec![
stroke(vec![0.0, 50.0, 100.0, 50.0]),
stroke(vec![50.0, 0.0, 50.0, 100.0]),
];
apply_line_jumps(&mut cmds, &[0, 1], "gap");
assert_eq!(count_strokes(&cmds), 3, "expected one split + one intact");
let first = polyline_points(&cmds[0]);
let second = polyline_points(&cmds[1]);
let last_x_first = first[first.len() - 2];
let first_x_second = second[0];
assert!(last_x_first < 50.0, "first piece ends before crossing");
assert!(first_x_second > 50.0, "second piece starts after crossing");
}
#[test]
fn no_crossing_no_change() {
let mut cmds = vec![
stroke(vec![0.0, 10.0, 100.0, 10.0]),
stroke(vec![0.0, 90.0, 100.0, 90.0]),
];
let before = cmds.clone();
let before_count = count_strokes(&cmds);
apply_line_jumps(&mut cmds, &[0, 1], "arc");
assert_eq!(count_strokes(&cmds), before_count);
assert_eq!(polyline_points(&cmds[0]), polyline_points(&before[0]));
assert_eq!(polyline_points(&cmds[1]), polyline_points(&before[1]));
let mut cmds_gap = before.clone();
apply_line_jumps(&mut cmds_gap, &[0, 1], "gap");
assert_eq!(count_strokes(&cmds_gap), before_count);
}
#[test]
fn determinism_arc() {
let base = vec![
stroke(vec![0.0, 50.0, 100.0, 50.0]),
stroke(vec![50.0, 0.0, 50.0, 100.0]),
];
let mut a = base.clone();
let mut b = base;
apply_line_jumps(&mut a, &[0, 1], "arc");
apply_line_jumps(&mut b, &[0, 1], "arc");
assert_eq!(polyline_points(&a[0]), polyline_points(&b[0]));
assert_eq!(polyline_points(&a[1]), polyline_points(&b[1]));
}
#[test]
fn touching_endpoint_no_hop() {
let mut cmds = vec![
stroke(vec![0.0, 50.0, 50.0, 50.0]),
stroke(vec![50.0, 50.0, 50.0, 100.0]),
];
let before = cmds.clone();
apply_line_jumps(&mut cmds, &[0, 1], "arc");
assert_eq!(polyline_points(&cmds[0]), polyline_points(&before[0]));
assert_eq!(polyline_points(&cmds[1]), polyline_points(&before[1]));
}
#[test]
fn none_mode_no_op() {
let mut cmds = vec![
stroke(vec![0.0, 50.0, 100.0, 50.0]),
stroke(vec![50.0, 0.0, 50.0, 100.0]),
];
let before = cmds.clone();
apply_line_jumps(&mut cmds, &[0, 1], "none");
assert_eq!(count_strokes(&cmds), count_strokes(&before));
assert_eq!(polyline_points(&cmds[0]), polyline_points(&before[0]));
}
#[test]
fn record_includes_bracketed() {
let cmds = vec![
SceneCommand::PushTransform {
angle_deg: 10.0,
cx: 0.0,
cy: 0.0,
},
stroke(vec![0.0, 0.0, 10.0, 10.0]),
SceneCommand::PopTransform,
];
let mut out = Vec::new();
record_connector_stroke(&cmds, 0, &mut out);
assert_eq!(out, vec![1], "stroke index recorded regardless of bracket");
}
#[test]
fn record_plain_connector() {
let cmds = vec![stroke(vec![0.0, 0.0, 10.0, 10.0])];
let mut out = Vec::new();
record_connector_stroke(&cmds, 0, &mut out);
assert_eq!(out, vec![0]);
}
#[test]
fn active_transform_classifies_depth() {
let cmds = vec![
SceneCommand::PushClip {
x: 0.0,
y: 0.0,
w: 100.0,
h: 100.0,
},
stroke(vec![0.0, 50.0, 100.0, 50.0]),
SceneCommand::PushTransform {
angle_deg: 30.0,
cx: 50.0,
cy: 50.0,
},
stroke(vec![50.0, 0.0, 50.0, 100.0]),
SceneCommand::PushTransform {
angle_deg: 15.0,
cx: 10.0,
cy: 10.0,
},
stroke(vec![0.0, 0.0, 10.0, 10.0]),
SceneCommand::PopTransform,
SceneCommand::PopTransform,
];
assert!(
matches!(active_transform_at(&cmds, 1), Some(Transform::Identity)),
"stroke at idx 1 is identity (clip only)"
);
assert!(
matches!(
active_transform_at(&cmds, 3),
Some(Transform::Rotate {
angle_deg: 30.0,
..
})
),
"stroke at idx 3 is a single rotation"
);
assert!(
active_transform_at(&cmds, 5).is_none(),
"stroke at idx 5 is under two rotations → excluded"
);
}
#[test]
fn rotate_pt_inverse_round_trips() {
let p = (1.0, 0.0);
let r = rotate_pt(p, 90.0, (0.0, 0.0));
assert!(
(r.0 - 0.0).abs() < 1e-9 && (r.1 - 1.0).abs() < 1e-9,
"{r:?}"
);
let back = rotate_pt(r, -90.0, (0.0, 0.0));
assert!(
(back.0 - 1.0).abs() < 1e-9 && (back.1 - 0.0).abs() < 1e-9,
"{back:?}"
);
}
#[test]
fn arc_depth_one_connector_participates() {
let mut cmds = vec![
SceneCommand::PushTransform {
angle_deg: 90.0,
cx: 50.0,
cy: 50.0,
},
stroke(vec![0.0, 50.0, 100.0, 50.0]),
SceneCommand::PopTransform,
stroke(vec![0.0, 50.0, 100.0, 50.0]),
];
let before_rot = polyline_points(&cmds[1]);
let before_plain = polyline_points(&cmds[3]);
apply_line_jumps(&mut cmds, &[1, 3], "arc");
let after_rot = polyline_points(&cmds[1]);
let after_plain = polyline_points(&cmds[3]);
assert_eq!(
after_rot, before_rot,
"rotated (on-page vertical) connector must be unchanged"
);
assert!(
after_plain.len() > before_plain.len(),
"plain on-page-horizontal connector should gain bump points: {after_plain:?}"
);
}
#[test]
fn depth_two_connector_excluded() {
let mut cmds = vec![
SceneCommand::PushTransform {
angle_deg: 10.0,
cx: 50.0,
cy: 50.0,
},
SceneCommand::PushTransform {
angle_deg: 20.0,
cx: 50.0,
cy: 50.0,
},
stroke(vec![50.0, 0.0, 50.0, 100.0]),
SceneCommand::PopTransform,
SceneCommand::PopTransform,
stroke(vec![0.0, 50.0, 100.0, 50.0]),
];
let before_inner = polyline_points(&cmds[2]);
let before_plain = polyline_points(&cmds[5]);
apply_line_jumps(&mut cmds, &[2, 5], "arc");
assert_eq!(
polyline_points(&cmds[2]),
before_inner,
"depth-2 connector is excluded → unchanged"
);
assert_eq!(
polyline_points(&cmds[5]),
before_plain,
"plain connector has no surviving partner → unchanged"
);
}
#[test]
fn arc_depth_zero_byte_identical_known_values() {
let mut cmds = vec![
stroke(vec![0.0, 50.0, 100.0, 50.0]),
stroke(vec![50.0, 0.0, 50.0, 100.0]),
];
apply_line_jumps(&mut cmds, &[0, 1], "arc");
let after_h = polyline_points(&cmds[0]);
let expected = rebuild_points_with_bumps(
&[0.0, 50.0, 100.0, 50.0],
&[Hop {
seg: 0,
px: 50.0,
py: 50.0,
dist_from_start: 50.0,
}],
);
assert_eq!(
after_h, expected,
"depth-0 arc output must be byte-identical to the un-rotated builder"
);
assert_eq!(polyline_points(&cmds[1]), vec![50.0, 0.0, 50.0, 100.0]);
}
}