Push 3 (slur quality) 2/3: split break-spanning slur curves (de Casteljau)
E2 drew a slur spanning a system break whole in its start system, with a floating end control point detached from its end note (the documented Minimal boundary). Casting now SPLITS such a curve into per-system sub-curves. curve_system → curve_fate, mirroring stroke_fate: a curve that fits in one system rides it whole (CurveFate::Rigid, byte-identical to before); a curve overlapping ≥2 systems' clip intervals splits (CurveFate::Split) into one sub-cubic per system, cut at each system's content clip edges. The cut uses de Casteljau subdivision (sub_cubic = two splits: take [0,t1], then its [t0/t1,1] tail) at the parameters param_at_x finds by bisecting the x-monotonic curve (a slur's control points are x-ascending by construction; a non-monotonic curve — not engraver-produced — falls back to riding its start system whole). The first segment carries the slur's exact provenance (the round-trip surjection recovers the source once); later segments synthesize continuations under SYSTEM_CONTINUATION_SYNTHESIS with a (stable_id, ordinal) key, as split strokes do. Each segment's control hull grows its own system's extent. ENGRAVER_VERSION 6→7 (a break-spanning slur's baked geometry differs); a slur that fits in one system is unchanged, so the fixture goldens are byte-identical (its slurs are short). Tests: sub_cubic reproduces the original curve on its sub-range + param_at_x inverts x; a whole-score slur splits into ≥2 segments across distinct system y-bands with correct provenance. 934 tests, 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5173d275e
commit
586969125e
|
|
@ -460,15 +460,29 @@ a `curve_count=0` line (a new tracked primitive kind), and the new
|
|||
- `HorizontalRemap::curves` re-maps each curve's four control-point x's through
|
||||
the same coordinate map as a spanning stroke's endpoints (a slur is never
|
||||
rigid-width); y preserved.
|
||||
- Casting: a `curve_system` assigns each curve WHOLE to the system containing
|
||||
its start control point and translates it by that system's placement — no
|
||||
split. An honest cubic split across a system break needs de Casteljau
|
||||
subdivision (the linear stroke split is wrong for a bézier), deferred; a
|
||||
break-spanning slur draws whole in its start system (a documented Minimal
|
||||
boundary). A curve's control-point hull grows its system's extent, so a slur
|
||||
above the staff raises the system height for page overflow, like a volta
|
||||
bracket.
|
||||
- Casting: a `curve_fate` (originally `curve_system`) rides a curve WHOLE in one
|
||||
system when it fits, and — since Push 3 (see below) — **splits** a curve
|
||||
spanning a system break into per-system sub-cubics by de Casteljau. A curve's
|
||||
control-point hull grows its system's extent, so a slur above the staff raises
|
||||
the system height for page overflow, like a volta bracket.
|
||||
- `slur_shape_penalty` stops being *vacuous* 0.0 and becomes *0.0 by
|
||||
construction*: the Minimal tier draws the ideal arc (or the authored
|
||||
override), so a drawn slur has zero shape deviation. A real penalty awaits a
|
||||
collision-aware Standard-tier solver that compromises a slur's shape.
|
||||
|
||||
## ENGRAVER_VERSION 6 → 7: curve splitting across systems (Push 3, 2026-07-08)
|
||||
|
||||
A slur spanning a system break now **splits** into per-system sub-curves
|
||||
(`curve_fate` → `CurveFate::Split`) by **de Casteljau** subdivision at the
|
||||
parameters where the curve crosses each system's content clip edges, replacing
|
||||
E2's draw-whole-in-start-system (the floating-end Minimal boundary). The first
|
||||
segment carries the slur's exact provenance (the round-trip source surjection
|
||||
recovers it once); later segments are synthesized continuations under
|
||||
`SYSTEM_CONTINUATION_SYNTHESIS` with a `(stable_id, ordinal)` key — exactly a
|
||||
split stroke's discipline. The split needs an x-monotonic curve to invert
|
||||
`x → t` (a slur is, its control points x-ascending by construction; `param_at_x`
|
||||
bisects); a non-monotonic curve (not produced by the engraver) falls back to
|
||||
riding its start system whole. `ENGRAVER_VERSION` 6 → 7 — but only a
|
||||
break-spanning slur's baked geometry changes; a slur that fits in one system is
|
||||
`CurveFate::Rigid`, byte-identical to version 6, so the existing goldens are
|
||||
unchanged (the fixture's slurs are short).
|
||||
|
|
|
|||
|
|
@ -267,6 +267,17 @@ enum StrokeFate {
|
|||
Split(Vec<(usize, Point, Point)>),
|
||||
}
|
||||
|
||||
/// A curve's casting fate: ride one system rigidly, or split at system
|
||||
/// boundaries into per-system sub-cubics (de Casteljau).
|
||||
enum CurveFate {
|
||||
/// Translate the whole curve with this system (`None`: not covered by any
|
||||
/// region).
|
||||
Rigid(Option<usize>),
|
||||
/// Per-system sub-cubics, ascending system order: `(system, control points)`
|
||||
/// in spaced coordinates.
|
||||
Split(Vec<(usize, [Point; 4])>),
|
||||
}
|
||||
|
||||
/// The content extent of a system in spaced (pre-casting) coordinates.
|
||||
#[derive(Copy, Clone)]
|
||||
struct Extent {
|
||||
|
|
@ -547,20 +558,12 @@ pub(crate) fn cast_off(
|
|||
)
|
||||
})
|
||||
.collect();
|
||||
// Curves ride one system whole (Minimal boundary: no de Casteljau split) —
|
||||
// the system whose clip interval contains the start control point, found
|
||||
// via the same nearest-region logic strokes use.
|
||||
let curve_systems: Vec<Option<usize>> = spaced_curves
|
||||
// A curve rides one system whole when it fits within one, or splits into
|
||||
// per-system sub-cubics (de Casteljau) when it spans a break — the same
|
||||
// nearest-region / clip-overlap logic strokes use.
|
||||
let curve_fates: Vec<CurveFate> = spaced_curves
|
||||
.iter()
|
||||
.map(|curve| {
|
||||
curve_system(
|
||||
curve,
|
||||
&system_of_slot,
|
||||
®ion_spans,
|
||||
®ion_systems,
|
||||
&clips,
|
||||
)
|
||||
})
|
||||
.map(|curve| curve_fate(curve, ®ion_spans, ®ion_systems, &clips))
|
||||
.collect();
|
||||
|
||||
// ---- System extents ----------------------------------------------------
|
||||
|
|
@ -603,17 +606,28 @@ pub(crate) fn cast_off(
|
|||
}
|
||||
// A curve's control-point hull (± half-thickness) grows its system's
|
||||
// extent, so a slur above the staff raises the system height (page overflow
|
||||
// accounts for it) exactly as the volta bracket strokes do.
|
||||
for (system, curve) in curve_systems.iter().zip(spaced_curves) {
|
||||
let Some(s) = system else { continue };
|
||||
// accounts for it) exactly as the volta bracket strokes do. A split curve
|
||||
// grows each system by its own sub-cubic's hull.
|
||||
for (fate, curve) in curve_fates.iter().zip(spaced_curves) {
|
||||
let half = (curve.thickness.0 * 0.5).max(0.0);
|
||||
for point in curve.control_points() {
|
||||
extents[*s].add(
|
||||
point.x.0 - half,
|
||||
point.y.0 - half,
|
||||
point.x.0 + half,
|
||||
point.y.0 + half,
|
||||
);
|
||||
let mut grow = |s: usize, cp: &[Point; 4]| {
|
||||
for point in cp {
|
||||
extents[s].add(
|
||||
point.x.0 - half,
|
||||
point.y.0 - half,
|
||||
point.x.0 + half,
|
||||
point.y.0 + half,
|
||||
);
|
||||
}
|
||||
};
|
||||
match fate {
|
||||
CurveFate::Rigid(Some(s)) => grow(*s, &curve.control_points()),
|
||||
CurveFate::Rigid(None) => {}
|
||||
CurveFate::Split(segments) => {
|
||||
for (s, cp) in segments {
|
||||
grow(*s, cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let extents: Vec<Extent> = extents.into_iter().map(Extent::normalized).collect();
|
||||
|
|
@ -766,31 +780,65 @@ pub(crate) fn cast_off(
|
|||
}
|
||||
strokes.extend(continuations);
|
||||
|
||||
// Curves: each translated whole by its start system's placement (or left
|
||||
// in the spaced frame if no region claimed it — same fallback as a
|
||||
// Rigid(None) stroke). A curve whose span crosses an internal system break
|
||||
// is a documented Minimal boundary: it is drawn whole in its start system,
|
||||
// so its end control point lands at (spaced end x + start-system delta) —
|
||||
// visually detached from its end note, which cast to a later system. The
|
||||
// curve is kept regardless (dropping it would break the round-trip source
|
||||
// surjection: the slur's source must be recovered from a resolved
|
||||
// primitive); an honest split needs de Casteljau subdivision, deferred to
|
||||
// a later tier.
|
||||
let curves: Vec<Curve> = spaced_curves
|
||||
.iter()
|
||||
.zip(&curve_systems)
|
||||
.map(|(curve, system)| {
|
||||
let (dx, dy) = system.map(|s| placements[s]).unwrap_or((0.0, 0.0));
|
||||
let shift = |point: Point| Point::new(point.x.0 + dx, point.y.0 + dy);
|
||||
Curve {
|
||||
p0: shift(curve.p0),
|
||||
p1: shift(curve.p1),
|
||||
p2: shift(curve.p2),
|
||||
p3: shift(curve.p3),
|
||||
..curve.clone()
|
||||
// Curves: a curve that fits in one system is translated whole by that
|
||||
// system's placement (or left in the spaced frame if no region claimed it).
|
||||
// A curve that spans a system break is split into per-system sub-cubics: the
|
||||
// first segment carries the slur's exact provenance (the object survives,
|
||||
// re-shaped — the round-trip source surjection recovers it), later segments
|
||||
// are synthesized continuations under `SYSTEM_CONTINUATION_SYNTHESIS`, as a
|
||||
// split stroke's are.
|
||||
let mut curves: Vec<Curve> = Vec::with_capacity(spaced_curves.len());
|
||||
let mut curve_continuations: Vec<Curve> = Vec::new();
|
||||
for (curve, fate) in spaced_curves.iter().zip(&curve_fates) {
|
||||
let shift =
|
||||
|cp: [Point; 4], dx: f32, dy: f32| cp.map(|p| Point::new(p.x.0 + dx, p.y.0 + dy));
|
||||
match fate {
|
||||
CurveFate::Rigid(system) => {
|
||||
let (dx, dy) = system.map(|s| placements[s]).unwrap_or((0.0, 0.0));
|
||||
let [p0, p1, p2, p3] = shift(curve.control_points(), dx, dy);
|
||||
curves.push(Curve {
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
..curve.clone()
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
CurveFate::Split(segments) => {
|
||||
for (k, (s, cp)) in segments.iter().enumerate() {
|
||||
let (dx, dy) = placements[*s];
|
||||
let [p0, p1, p2, p3] = shift(*cp, dx, dy);
|
||||
let provenance = if k == 0 {
|
||||
curve.provenance.clone()
|
||||
} else {
|
||||
Provenance::synthesized(
|
||||
curve.provenance.source,
|
||||
SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS),
|
||||
continuation_instance_key(curve.provenance.stable_id, k as u32),
|
||||
curve.provenance.dependencies.clone(),
|
||||
)
|
||||
};
|
||||
let segment = Curve {
|
||||
provenance,
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
thickness: curve.thickness,
|
||||
layer: curve.layer,
|
||||
style: curve.style,
|
||||
line: curve.line,
|
||||
};
|
||||
if k == 0 {
|
||||
curves.push(segment);
|
||||
} else {
|
||||
curve_continuations.push(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
curves.extend(curve_continuations);
|
||||
|
||||
// ---- The resolved page tree ---------------------------------------------
|
||||
let resolved_systems: Vec<ResolvedSystem> = systems
|
||||
|
|
@ -1273,15 +1321,14 @@ fn stroke_fate(
|
|||
/// left in the spaced frame, on no page). A curve is never split — an honest
|
||||
/// cubic split across a system break needs de Casteljau subdivision, deferred
|
||||
/// to a later tier; here a break-spanning slur draws whole in its start system.
|
||||
fn curve_system(
|
||||
fn curve_fate(
|
||||
curve: &Curve,
|
||||
system_of_slot: &BTreeMap<SpringSlotId, usize>,
|
||||
region_spans: &[Option<(f32, f32)>],
|
||||
region_systems: &[Vec<usize>],
|
||||
clips: &[(f32, f32)],
|
||||
) -> Option<usize> {
|
||||
let _ = system_of_slot;
|
||||
let xs = curve.control_points().map(|p| p.x.0);
|
||||
) -> CurveFate {
|
||||
let cp = curve.control_points();
|
||||
let xs = cp.map(|p| p.x.0);
|
||||
let lo = xs.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let hi = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
// The owning region: the one whose slot span is nearest (ties to the first).
|
||||
|
|
@ -1293,14 +1340,112 @@ fn curve_system(
|
|||
best = Some((r, distance));
|
||||
}
|
||||
}
|
||||
let (region, _) = best?;
|
||||
// The start control point pins which system the whole curve rides.
|
||||
let start_x = curve.p0.x.0;
|
||||
region_systems[region].iter().copied().min_by(|&a, &b| {
|
||||
let da = interval_distance(start_x, start_x, clips[a]);
|
||||
let db = interval_distance(start_x, start_x, clips[b]);
|
||||
da.total_cmp(&db).then(a.cmp(&b))
|
||||
})
|
||||
let Some((region, _)) = best else {
|
||||
return CurveFate::Rigid(None);
|
||||
};
|
||||
// The systems of that region the curve's x-span overlaps.
|
||||
let overlapped: Vec<usize> = region_systems[region]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&s| lo <= clips[s].1 && hi >= clips[s].0)
|
||||
.collect();
|
||||
// The start control point pins which single system the curve rides when it
|
||||
// does not span a break.
|
||||
let start_system = || {
|
||||
region_systems[region].iter().copied().min_by(|&a, &b| {
|
||||
let da = interval_distance(cp[0].x.0, cp[0].x.0, clips[a]);
|
||||
let db = interval_distance(cp[0].x.0, cp[0].x.0, clips[b]);
|
||||
da.total_cmp(&db).then(a.cmp(&b))
|
||||
})
|
||||
};
|
||||
match overlapped.len() {
|
||||
0 => CurveFate::Rigid(start_system()),
|
||||
1 => CurveFate::Rigid(Some(overlapped[0])),
|
||||
_ => {
|
||||
// A curve spanning a system break is split into per-system
|
||||
// sub-curves by de Casteljau subdivision at the parameters where it
|
||||
// crosses each system's content clip edges. This needs an
|
||||
// x-monotonic curve to invert `x -> t`; a slur is (its control
|
||||
// points are x-ascending by construction). A non-monotonic curve
|
||||
// (not produced by the engraver) cannot be honestly split, so it
|
||||
// rides its start system whole.
|
||||
if !is_x_monotonic(cp) {
|
||||
return CurveFate::Rigid(start_system());
|
||||
}
|
||||
let segments = overlapped
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (clo, chi) = clips[s];
|
||||
let x0 = clo.max(cp[0].x.0);
|
||||
let x1 = chi.min(cp[3].x.0);
|
||||
let t0 = param_at_x(cp, x0);
|
||||
let t1 = param_at_x(cp, x1);
|
||||
(s, sub_cubic(cp, t0, t1))
|
||||
})
|
||||
.collect();
|
||||
CurveFate::Split(segments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cubic's control points ascend in x (so `x -> t` is invertible by
|
||||
/// bisection), with a non-trivial x-span.
|
||||
fn is_x_monotonic(cp: [Point; 4]) -> bool {
|
||||
cp[0].x.0 <= cp[1].x.0
|
||||
&& cp[1].x.0 <= cp[2].x.0
|
||||
&& cp[2].x.0 <= cp[3].x.0
|
||||
&& cp[3].x.0 > cp[0].x.0
|
||||
}
|
||||
|
||||
/// The parameter `t` at which an x-monotonic cubic's x-coordinate equals `x`
|
||||
/// (bisection; `x` is clamped to the curve's x-range by the caller).
|
||||
fn param_at_x(cp: [Point; 4], x: f32) -> f32 {
|
||||
let cubic_x = |t: f32| {
|
||||
let u = 1.0 - t;
|
||||
u * u * u * cp[0].x.0
|
||||
+ 3.0 * u * u * t * cp[1].x.0
|
||||
+ 3.0 * u * t * t * cp[2].x.0
|
||||
+ t * t * t * cp[3].x.0
|
||||
};
|
||||
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
|
||||
for _ in 0..40 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if cubic_x(mid) < x {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
/// Linear interpolation between two points.
|
||||
fn lerp_point(a: Point, b: Point, t: f32) -> Point {
|
||||
Point::new(a.x.0 + (b.x.0 - a.x.0) * t, a.y.0 + (b.y.0 - a.y.0) * t)
|
||||
}
|
||||
|
||||
/// de Casteljau split of a cubic at `t`: `(left [0, t], right [t, 1])`.
|
||||
fn split_cubic(cp: [Point; 4], t: f32) -> ([Point; 4], [Point; 4]) {
|
||||
let a = lerp_point(cp[0], cp[1], t);
|
||||
let b = lerp_point(cp[1], cp[2], t);
|
||||
let c = lerp_point(cp[2], cp[3], t);
|
||||
let d = lerp_point(a, b, t);
|
||||
let e = lerp_point(b, c, t);
|
||||
let f = lerp_point(d, e, t);
|
||||
([cp[0], a, d, f], [f, e, c, cp[3]])
|
||||
}
|
||||
|
||||
/// The sub-cubic of `cp` over the parameter range `[t0, t1]` (two de Casteljau
|
||||
/// splits: take `[0, t1]`, then within it the `[t0/t1, 1]` tail).
|
||||
fn sub_cubic(cp: [Point; 4], t0: f32, t1: f32) -> [Point; 4] {
|
||||
let (left, _) = split_cubic(cp, t1);
|
||||
let tt = if t1 > f32::EPSILON {
|
||||
(t0 / t1).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (_, right) = split_cubic(left, tt);
|
||||
right
|
||||
}
|
||||
|
||||
/// Distance from the span `[lo, hi]` to a clip interval (0 when they overlap).
|
||||
|
|
@ -1624,4 +1769,123 @@ mod tests {
|
|||
"the rebalanced final system is not a stub: {last} vs {first}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_cubic_reproduces_the_original_curve_on_its_sub_range() {
|
||||
// de Casteljau correctness: the sub-cubic over [t0, t1], evaluated at
|
||||
// its own parameter u in [0, 1], equals the original evaluated at
|
||||
// t0 + u·(t1 - t0). A slur-shaped x-ascending cubic.
|
||||
let cp = [
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(2.0, 3.0),
|
||||
Point::new(6.0, 3.0),
|
||||
Point::new(8.0, 0.0),
|
||||
];
|
||||
let eval = |p: [Point; 4], t: f32| -> Point {
|
||||
let u = 1.0 - t;
|
||||
Point::new(
|
||||
u * u * u * p[0].x.0
|
||||
+ 3.0 * u * u * t * p[1].x.0
|
||||
+ 3.0 * u * t * t * p[2].x.0
|
||||
+ t * t * t * p[3].x.0,
|
||||
u * u * u * p[0].y.0
|
||||
+ 3.0 * u * u * t * p[1].y.0
|
||||
+ 3.0 * u * t * t * p[2].y.0
|
||||
+ t * t * t * p[3].y.0,
|
||||
)
|
||||
};
|
||||
let (t0, t1) = (0.3_f32, 0.75_f32);
|
||||
let sub = sub_cubic(cp, t0, t1);
|
||||
for i in 0..=10 {
|
||||
let u = i as f32 / 10.0;
|
||||
let on_sub = eval(sub, u);
|
||||
let on_orig = eval(cp, t0 + u * (t1 - t0));
|
||||
assert!(
|
||||
(on_sub.x.0 - on_orig.x.0).abs() < 1e-4 && (on_sub.y.0 - on_orig.y.0).abs() < 1e-4,
|
||||
"sub-cubic diverges from the original at u={u}: {on_sub:?} vs {on_orig:?}"
|
||||
);
|
||||
}
|
||||
// And `param_at_x` inverts the x-monotonic curve: the point at the found
|
||||
// parameter has the requested x.
|
||||
assert!(is_x_monotonic(cp));
|
||||
let t = param_at_x(cp, 5.0);
|
||||
assert!((eval(cp, t).x.0 - 5.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slur_spanning_a_system_break_splits_into_per_system_sub_curves() {
|
||||
use crate::Engraver;
|
||||
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId};
|
||||
use epiphany_layout_ir::{
|
||||
to_constrained, to_logical, ConstraintSolver, SolverConfig, SynthesisKind,
|
||||
};
|
||||
// A slur over the whole ten-measure score — its endpoints cast into
|
||||
// different systems (the fixture wraps into two), so the curve spans the
|
||||
// break.
|
||||
let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
|
||||
let events: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
|
||||
.events
|
||||
.clone();
|
||||
let slur_id: SlurId = score.identity.mint();
|
||||
score.cross_cutting.slurs.push(Slur {
|
||||
id: slur_id,
|
||||
start_event: events[0],
|
||||
end_event: events[events.len() - 1],
|
||||
kind: SlurKind::Legato,
|
||||
curvature_override: None,
|
||||
style: SpanStyle::default(),
|
||||
});
|
||||
let report = Engraver::default().solve(
|
||||
&to_constrained(&to_logical(&score)),
|
||||
&SolverConfig::default(),
|
||||
);
|
||||
assert_eq!(report.layout.pages[0].systems.len(), 2, "two systems");
|
||||
|
||||
let slur_curves: Vec<_> = report
|
||||
.layout
|
||||
.curves
|
||||
.iter()
|
||||
.filter(|c| c.provenance.source == TypedObjectId::Slur(slur_id))
|
||||
.collect();
|
||||
// The slur split into ≥2 sub-cubics (one per spanned system).
|
||||
assert!(
|
||||
slur_curves.len() >= 2,
|
||||
"a break-spanning slur splits, got {} segment(s)",
|
||||
slur_curves.len()
|
||||
);
|
||||
// Exactly one segment carries the slur's exact provenance (the surjection
|
||||
// recovers the source once); the rest are synthesized continuations.
|
||||
let originals = slur_curves
|
||||
.iter()
|
||||
.filter(|c| c.provenance.synthesis.is_none())
|
||||
.count();
|
||||
assert_eq!(
|
||||
originals, 1,
|
||||
"one segment keeps the slur's exact provenance"
|
||||
);
|
||||
assert!(slur_curves
|
||||
.iter()
|
||||
.filter(|c| c.provenance.synthesis.is_some())
|
||||
.all(|c| matches!(c.provenance.synthesis, Some(SynthesisKind::Registered(_)))));
|
||||
// The segments sit in different systems, which casting stacks
|
||||
// vertically (each system is translated down and restarts x at the left
|
||||
// margin), so a real split separates them in Y — one curve overhanging
|
||||
// into the next system would keep a single y-band.
|
||||
let y_centroids: Vec<f32> = slur_curves
|
||||
.iter()
|
||||
.map(|c| (c.p0.y.0 + c.p1.y.0 + c.p2.y.0 + c.p3.y.0) / 4.0)
|
||||
.collect();
|
||||
let (lo, hi) = (
|
||||
y_centroids.iter().copied().fold(f32::INFINITY, f32::min),
|
||||
y_centroids
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f32::NEG_INFINITY, f32::max),
|
||||
);
|
||||
assert!(
|
||||
hi - lo > 1.0,
|
||||
"the segments span distinct system y-bands (a real split), spread {}",
|
||||
hi - lo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,11 @@ pub struct Engraver {
|
|||
/// differ; slur-free scores draw the same ink as before), and to `6` when slur
|
||||
/// curves gained a line pattern (a dashed or dotted slur renders its authored
|
||||
/// `SpanStyle.line` faithfully; a curve's canonical bytes now include its line
|
||||
/// style; solid slurs and slur-free scores are unchanged).
|
||||
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(6);
|
||||
/// style; solid slurs and slur-free scores are unchanged), and to `7` when a
|
||||
/// slur spanning a system break began splitting into per-system sub-curves (de
|
||||
/// Casteljau) instead of drawing whole in its start system; a slur that fits in
|
||||
/// one system is unchanged.
|
||||
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(7);
|
||||
|
||||
impl Engraver {
|
||||
/// An engraver casting off against the given page geometry.
|
||||
|
|
|
|||
Loading…
Reference in New Issue