use super::super::ir::{Bbox, PlacedNode};
use super::geometry::{self, P, PathSeg, Subpath, arc_center, dist, n};
use crate::error::Error;
use crate::resolve::{ResolvedInst, ResolvedValue};
use crate::span::Span;
const BREAK_GAP: f64 = 12.0;
const OVERHANG: f64 = 4.0;
const EPS: f64 = 1e-6;
#[derive(Debug, Default)]
pub struct ViewMap {
x: Vec<(f64, f64)>,
y: Vec<(f64, f64)>,
}
impl ViewMap {
pub fn is_identity(&self) -> bool {
self.x.is_empty() && self.y.is_empty()
}
pub fn map(&self, p: P) -> P {
(forward(&self.x, p.0), forward(&self.y, p.1))
}
pub fn unmap(&self, p: P) -> P {
(backward(&self.x, p.0), backward(&self.y, p.1))
}
pub fn segment(&self, s: super::Segment) -> super::Segment {
use super::Segment;
match s {
Segment::Point(p) => Segment::Point(self.map(p)),
Segment::Edge(a, b) => Segment::Edge(self.map(a), self.map(b)),
Segment::Arc { mid, r } => Segment::Arc {
mid: self.map(mid),
r,
},
Segment::Circle { center, r } => Segment::Circle {
center: self.map(center),
r,
},
}
}
}
fn forward(knots: &[(f64, f64)], t: f64) -> f64 {
interp(knots, t, |k| (k.0, k.1))
}
fn backward(knots: &[(f64, f64)], t: f64) -> f64 {
interp(knots, t, |k| (k.1, k.0))
}
fn interp(knots: &[(f64, f64)], t: f64, pick: impl Fn(&(f64, f64)) -> (f64, f64)) -> f64 {
let Some(first) = knots.first() else {
return t;
};
let (f_in, f_out) = pick(first);
if t <= f_in {
return t - f_in + f_out;
}
for w in knots.windows(2) {
let (a_in, a_out) = pick(&w[0]);
let (b_in, b_out) = pick(&w[1]);
if t <= b_in {
let f = (t - a_in) / (b_in - a_in);
return a_out + f * (b_out - a_out);
}
}
let (l_in, l_out) = pick(knots.last().expect("non-empty"));
t - l_in + l_out
}
#[derive(Debug)]
pub struct CutEdge {
pub horizontal: bool,
pub t: f64,
pub lo: f64,
pub hi: f64,
}
struct Group {
a: f64,
b: f64,
vertical: bool,
}
pub(super) fn apply(
inst: &ResolvedInst,
subs: &mut Vec<Subpath>,
scale: f64,
span: Span,
) -> Result<(ViewMap, Vec<CutEdge>), Error> {
let Some(value) = inst.attrs.get("break") else {
return Ok((ViewMap::default(), Vec::new()));
};
let model = geometry::geometry_bbox(&geometry::to_d(subs));
let groups = parse(value, model, scale, span)?;
let view = build_map(&groups, span)?;
let mut cuts = Vec::with_capacity(groups.len() * 2);
for g in &groups {
let (kept, xa, xb) = clip_out(std::mem::take(subs), g.vertical, g.a, g.b, span)?;
*subs = kept;
for (station, crossings) in [(g.a, xa), (g.b, xb)] {
let (Some(lo), Some(hi)) = (
crossings.iter().copied().min_by(f64::total_cmp),
crossings.iter().copied().max_by(f64::total_cmp),
) else {
return Err(Error::at(
span,
format!("'break' at {} misses the profile", n(station / scale)),
));
};
let t = if g.vertical {
forward(&view.y, station)
} else {
forward(&view.x, station)
};
let across = if g.vertical { &view.x } else { &view.y };
cuts.push(CutEdge {
horizontal: g.vertical,
t,
lo: forward(across, lo),
hi: forward(across, hi),
});
}
}
displace(subs, &view);
Ok((view, cuts))
}
fn parse(value: &ResolvedValue, model: Bbox, scale: f64, span: Span) -> Result<Vec<Group>, Error> {
let bad = || {
Error::at(
span,
"'break' takes two stations 'a b' — a < b — and an optional x-axis / y-axis",
)
};
let longer_is_y = model.h() > model.w();
let one = |v: &ResolvedValue| -> Result<Group, Error> {
let ResolvedValue::Tuple(items) = v else {
return Err(bad());
};
let (nums, rest) = match items.as_slice() {
[a, b] => ([a, b], None),
[a, b, axis] => ([a, b], Some(axis)),
_ => return Err(bad()),
};
let (Some(a), Some(b)) = (nums[0].as_number(), nums[1].as_number()) else {
return Err(bad());
};
if a >= b {
return Err(bad());
}
let vertical = match rest {
None => longer_is_y,
Some(ResolvedValue::Ident(s)) if s == "x-axis" => false,
Some(ResolvedValue::Ident(s)) if s == "y-axis" => true,
Some(_) => return Err(bad()),
};
Ok(Group {
a: a * scale,
b: b * scale,
vertical,
})
};
match value {
ResolvedValue::List(items) => items.iter().map(one).collect(),
v => Ok(vec![one(v)?]),
}
}
fn build_map(groups: &[Group], span: Span) -> Result<ViewMap, Error> {
let mut view = ViewMap::default();
for vertical in [false, true] {
let mut cuts: Vec<(f64, f64)> = groups
.iter()
.filter(|g| g.vertical == vertical)
.map(|g| (g.a, g.b))
.collect();
cuts.sort_by(|p, q| p.0.total_cmp(&q.0));
let knots = if vertical { &mut view.y } else { &mut view.x };
let mut shift = 0.0;
for w in cuts.windows(2) {
if w[1].0 <= w[0].1 {
return Err(Error::at(span, "'break' spans overlap — merge them"));
}
}
for (a, b) in cuts {
knots.push((a, a - shift));
knots.push((b, a - shift + BREAK_GAP));
shift += (b - a) - BREAK_GAP;
}
}
Ok(view)
}
type Clipped = (Vec<Subpath>, Vec<f64>, Vec<f64>);
fn clip_out(
subs: Vec<Subpath>,
vertical: bool,
a: f64,
b: f64,
span: Span,
) -> Result<Clipped, Error> {
let t = |p: P| if vertical { p.1 } else { p.0 };
let cross = |p: P| if vertical { p.0 } else { p.1 };
let mut out = Vec::with_capacity(subs.len());
let (mut xa, mut xb) = (Vec::new(), Vec::new());
for sub in subs {
let mut pieces: Vec<(PathSeg, bool)> = Vec::with_capacity(sub.segs.len());
let mut any_dropped = false;
for seg in &sub.segs {
if let PathSeg::Cubic { from, c1, c2, to } = seg {
let ts = [t(*from), t(*c1), t(*c2), t(*to)];
let (lo, hi) = ts
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |acc, v| {
(acc.0.min(*v), acc.1.max(*v))
});
for station in [a, b] {
if lo < station - EPS && hi > station + EPS {
return Err(Error::at(
span,
"a 'break' can't cut a 'curve()' — move the stations",
));
}
}
}
for piece in split_seg(*seg, vertical, a, b) {
let m = t(piece_mid(&piece));
let keep = m <= a + EPS || m >= b - EPS;
any_dropped |= !keep;
pieces.push((piece, keep));
}
}
if !any_dropped {
out.push(sub);
continue;
}
let n = pieces.len();
let start = pieces
.iter()
.position(|(_, keep)| !keep)
.expect("something dropped");
let order: Vec<usize> = if sub.closed {
(1..=n).map(|i| (start + i) % n).collect()
} else {
(0..n).collect()
};
let mut run: Vec<PathSeg> = Vec::new();
let mut flush = |run: &mut Vec<PathSeg>| {
if run.is_empty() {
return;
}
for p in [
run.first().expect("non-empty").from(),
run.last().expect("non-empty").to(),
] {
if (t(p) - a).abs() < EPS {
xa.push(cross(p));
} else if (t(p) - b).abs() < EPS {
xb.push(cross(p));
}
}
out.push(Subpath {
segs: std::mem::take(run),
closed: false,
});
};
for i in order {
let (piece, keep) = &pieces[i];
if *keep {
if let Some(last) = run.last()
&& dist(last.to(), piece.from()) > EPS
{
flush(&mut run);
}
run.push(*piece);
} else {
flush(&mut run);
}
}
flush(&mut run);
}
Ok((out, xa, xb))
}
fn piece_mid(seg: &PathSeg) -> P {
match *seg {
PathSeg::Line { from, to } => ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0),
PathSeg::Arc {
from,
to,
r,
large,
sweep,
} => {
let c = arc_center(from, to, r, large, sweep);
let (a0, travel) = arc_travel(from, to, c, sweep);
arc_point(c, r, a0 + travel / 2.0 * if sweep { 1.0 } else { -1.0 })
}
PathSeg::Cubic { from, c1, c2, to } => (
(from.0 + 3.0 * c1.0 + 3.0 * c2.0 + to.0) / 8.0,
(from.1 + 3.0 * c1.1 + 3.0 * c2.1 + to.1) / 8.0,
),
}
}
fn split_seg(seg: PathSeg, vertical: bool, a: f64, b: f64) -> Vec<PathSeg> {
let t = |p: P| if vertical { p.1 } else { p.0 };
match seg {
PathSeg::Line { from, to } => {
let (t0, t1) = (t(from), t(to));
let mut cuts: Vec<f64> = [a, b]
.into_iter()
.filter_map(|c| {
let s = (c - t0) / (t1 - t0);
(s.is_finite() && s > EPS && s < 1.0 - EPS).then_some(s)
})
.collect();
cuts.sort_by(f64::total_cmp);
let at = |s: f64| (from.0 + (to.0 - from.0) * s, from.1 + (to.1 - from.1) * s);
let mut pts = vec![from];
pts.extend(cuts.into_iter().map(at));
pts.push(to);
pts.windows(2)
.map(|w| PathSeg::Line {
from: w[0],
to: w[1],
})
.collect()
}
PathSeg::Arc {
from,
to,
r,
large,
sweep,
} => {
let c = arc_center(from, to, r, large, sweep);
let (a0, travel) = arc_travel(from, to, c, sweep);
let dir = if sweep { 1.0 } else { -1.0 };
let mut hits: Vec<f64> = Vec::new();
for station in [a, b] {
let d = station - if vertical { c.1 } else { c.0 };
if d.abs() >= r - EPS {
continue;
}
let h = (r * r - d * d).sqrt();
for other in [h, -h] {
let p = if vertical {
(c.0 + other, station)
} else {
(station, c.1 + other)
};
let aq = (p.1 - c.1).atan2(p.0 - c.0);
let along = ((aq - a0) * dir).rem_euclid(std::f64::consts::TAU);
if along > EPS && along < travel - EPS {
hits.push(along);
}
}
}
hits.sort_by(f64::total_cmp);
hits.dedup_by(|p, q| (*p - *q).abs() < EPS);
let mut angles = vec![0.0];
angles.extend(hits);
angles.push(travel);
angles
.windows(2)
.map(|w| {
let (p, q) = (
arc_point(c, r, a0 + w[0] * dir),
arc_point(c, r, a0 + w[1] * dir),
);
PathSeg::Arc {
from: if w[0] == 0.0 { from } else { p },
to: if w[1] == travel { to } else { q },
r,
large: w[1] - w[0] > std::f64::consts::PI,
sweep,
}
})
.collect()
}
PathSeg::Cubic { .. } => vec![seg],
}
}
fn arc_travel(from: P, to: P, c: P, sweep: bool) -> (f64, f64) {
let a0 = (from.1 - c.1).atan2(from.0 - c.0);
let a1 = (to.1 - c.1).atan2(to.0 - c.0);
let dir = if sweep { 1.0 } else { -1.0 };
(a0, ((a1 - a0) * dir).rem_euclid(std::f64::consts::TAU))
}
fn arc_point(c: P, r: f64, angle: f64) -> P {
(c.0 + r * angle.cos(), c.1 + r * angle.sin())
}
fn displace(subs: &mut [Subpath], view: &ViewMap) {
for sub in subs.iter_mut() {
for seg in &mut sub.segs {
*seg = match *seg {
PathSeg::Line { from, to } => PathSeg::Line {
from: view.map(from),
to: view.map(to),
},
PathSeg::Arc {
from,
to,
r,
large,
sweep,
} => PathSeg::Arc {
from: view.map(from),
to: view.map(to),
r,
large,
sweep,
},
PathSeg::Cubic { from, c1, c2, to } => PathSeg::Cubic {
from: view.map(from),
c1: view.map(c1),
c2: view.map(c2),
to: view.map(to),
},
};
}
}
}
pub(in crate::layout) fn fill_chrome(children: &mut [PlacedNode], cuts: &[CutEdge]) {
for c in children.iter_mut() {
let Some(ResolvedValue::Tuple(items)) = c.attrs.get("chrome") else {
continue;
};
let [ResolvedValue::Ident(k), ResolvedValue::Number(idx)] = items.as_slice() else {
continue;
};
if k != "break" {
continue;
}
let Some(cut) = cuts.get(*idx as usize) else {
continue;
};
let half = c.attrs.number("stroke-width").unwrap_or(0.0) / 2.0;
let pt = |t: f64, s: f64| if cut.horizontal { (s, t) } else { (t, s) };
let pts = jogged(cut, pt);
let value = ResolvedValue::List(
pts.iter()
.map(|p| {
ResolvedValue::Tuple(vec![
ResolvedValue::Number(p.0),
ResolvedValue::Number(p.1),
])
})
.collect(),
);
c.attrs.insert("points", value);
c.bbox = bounds(&pts).inflate(half);
}
}
fn jogged(cut: &CutEdge, pt: impl Fn(f64, f64) -> P) -> Vec<P> {
let m = (cut.lo + cut.hi) / 2.0;
let h = cut.hi - cut.lo;
let jog = (h * 0.28).min(9.0);
let amp = (h * 0.2).min(4.5);
vec![
pt(cut.t, cut.lo - OVERHANG),
pt(cut.t, m - jog),
pt(cut.t + amp, m - jog * 0.15),
pt(cut.t - amp, m + jog * 0.15),
pt(cut.t, m + jog),
pt(cut.t, cut.hi + OVERHANG),
]
}
fn bounds(pts: &[P]) -> Bbox {
let mut b = Bbox {
min_x: f64::INFINITY,
min_y: f64::INFINITY,
max_x: f64::NEG_INFINITY,
max_y: f64::NEG_INFINITY,
};
for p in pts {
b.min_x = b.min_x.min(p.0);
b.min_y = b.min_y.min(p.1);
b.max_x = b.max_x.max(p.0);
b.max_y = b.max_y.max(p.1);
}
b
}
#[cfg(test)]
mod tests {
use super::super::testutil::{by_id, laid, layout_err, text_at};
use super::{BREAK_GAP, ViewMap};
use crate::resolve::NodeKind;
#[test]
fn the_view_map_squashes_the_span_and_round_trips() {
let v = ViewMap {
x: vec![(100.0, 100.0), (200.0, 100.0 + BREAK_GAP)],
..Default::default()
};
assert_eq!(v.map((50.0, 7.0)), (50.0, 7.0), "near is identity");
assert_eq!(v.map((300.0, 0.0)).0, 300.0 - 100.0 + BREAK_GAP);
assert_eq!(
v.map((150.0, 0.0)).0,
100.0 + BREAK_GAP / 2.0,
"mid-span squashes"
);
for t in [-20.0, 100.0, 137.0, 200.0, 450.0] {
let d = v.map((t, 0.0));
assert!((v.unmap(d).0 - t).abs() < 1e-9, "round-trip at {t}");
}
}
#[test]
fn a_break_compresses_the_view_and_the_dim_stays_true() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#bar| { draw: move(-150, 0) up(10) right(300) down(10); mirror: x-axis; break: -80 60 }\nbar:left (-) bar:right { side: bottom }\n",
);
let bar = by_id(&l.nodes, "bar");
assert!(
(bar.bbox.w() - (172.0 + 2.0)).abs() < 1e-6,
"compressed + stroke: {}",
bar.bbox.w()
);
text_at(&l.nodes, "300");
}
#[test]
fn break_defaults_to_the_longer_axis() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#post| { draw: move(-10, -60) right(20) down(120) left(20) close(); break: -30 30 }\n",
);
let post = by_id(&l.nodes, "post");
assert!(
(post.bbox.h() - (72.0 + 2.0)).abs() < 1e-6,
"120 − 60 + gap: {}",
post.bbox.h()
);
assert!((post.bbox.w() - 22.0).abs() < 1e-6, "x untouched");
}
#[test]
fn every_cut_edge_draws_the_jogged_break_line() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#plate| { draw: move(-100, -12) right(200) down(24) left(200) close(); break: -50 50 }\n",
);
let cuts: Vec<_> = by_id(&l.nodes, "plate")
.children
.iter()
.filter(|c| c.type_chain.iter().any(|t| t == "breakline"))
.collect();
assert_eq!(cuts.len(), 2, "one pair per group");
assert!(cuts.iter().all(|c| c.kind == NodeKind::Line));
assert!(
cuts.iter().any(|c| (c.bbox.min_x - -55.0).abs() < 1e-6),
"near cut at −50 − amp − half stroke: {}",
cuts[0].bbox.min_x
);
assert!(
cuts.iter().all(|c| (c.bbox.h() - 33.0).abs() < 1e-6),
"24 + 2 × overhang + stroke: {}",
cuts[0].bbox.h()
);
}
#[test]
fn a_station_in_the_removed_span_still_measures_true() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#bar| { draw: move(-150, 0) up(10) right(150):half point():mid right(150) down(10); mirror: x-axis; break: -80 60 }\nbar:left (-) bar:mid { side: bottom }\n",
);
text_at(&l.nodes, "150");
}
#[test]
fn a_revolved_station_span_reads_true_across_a_break() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#bar| { draw: move(-150, 0) up(10) right(40):thread right(260) down(10); revolve: x-axis; break: -80 60 }\nbar:thread (o) { side: left }\n",
);
text_at(&l.nodes, "⌀20");
}
#[test]
fn features_ride_the_broken_view_and_dims_to_them_stay_true() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#bar| { draw: move(-150, -15) right(300) down(30) left(300) close(); break: -80 60 } [\n |hole#vent| { width: 10; translate: 100 0 }\n]\nbar:left (-) bar.vent { side: bottom }\n",
);
let vent = by_id(&l.nodes, "vent");
assert!(
(vent.cx - -28.0).abs() < 1e-9,
"rigid with the far piece: {}",
vent.cx
);
text_at(&l.nodes, "250");
}
#[test]
fn pattern_copies_ride_the_broken_view_too() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#bar| { draw: move(-150, -15) right(300) down(30) left(300) close(); break: -30 30 } [\n |hole#vent| { width: 10; translate: -120 0; pattern: grid(3, 1, 80, 0) }\n]\n",
);
let vent = by_id(&l.nodes, "vent");
assert_eq!((vent.cx, vent.cy), (-120.0, 0.0), "the seed stays put");
let copies: Vec<f64> = vent
.children
.iter()
.filter(|c| c.attrs.get("chrome").is_none() && c.kind == NodeKind::Oval)
.map(|c| c.cx)
.collect();
assert_eq!(copies, vec![0.0, 80.0, 112.0], "the far copy slides");
}
#[test]
fn break_errors_speak_spec() {
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|rect#a| { width: 40; height: 20; break: -5 5 }\n"
),
"'break' cuts a '|sketch|' — draw the profile with the pen"
);
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close(); break: 30 10 }\n"
),
"'break' takes two stations 'a b' — a < b — and an optional x-axis / y-axis"
);
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close(); break: 90 100 }\n"
),
"'break' at 90 misses the profile"
);
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close(); break: 5 20, 15 35 }\n"
),
"'break' spans overlap — merge them"
);
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|sketch#s| { draw: move(0, 0) curve(20, -30, 40, 30, 60, 0) down(20) left(60) close(); break: 20 40 }\n"
),
"a 'break' can't cut a 'curve()' — move the stations"
);
}
#[test]
fn a_cut_through_an_arc_splits_it_clean() {
let l = laid(
"{ layout: drawing; scale: 1 }\n|sketch#dome| { draw: move(-50, 0) arc(100, 0, 50) close(); break: -20 20 }\n",
);
let dome = by_id(&l.nodes, "dome");
assert!(
(dome.bbox.w() - 74.0).abs() < 0.1,
"arc clipped and compressed: {}",
dome.bbox.w()
);
}
}