Push 3 (slur quality) 3/3: measure slur_shape_penalty (off the 0.0 placeholder)
The slur_shape quality axis was pinned at 0.0 (vacuous, then "by construction"). It now MEASURES, per the Quality Metric Catalog (req:qmc:slur): each drawn slur Curve's arc ratio ρ = apex height / chord length is penalized by its distance outside the shallow-arc band [0.08, 0.25] (max(0, 0.08-ρ, ρ-0.25)), meaned over curves, normalized by R_worst=0.25. Apex is the max perpendicular distance from the sampled cubic (32 points) to its endpoint chord; translation-invariant, so the post-cast curves are measured directly. Honest outcome: the Minimal tier's mid-span slurs sit at ρ = SLUR_HEIGHT_FACTOR = 0.16 (in band → 0), but the fixed min/max height clamps push short slurs above the band (bulgy) and very long ones below it (flat) — a genuine non-zero value a duration-aware Standard-tier height would improve. A curve-free layout measures 0 by the vacuous-geometry rule. No ENGRAVER_VERSION bump — measurement-only, resolved geometry / canonical bytes / render goldens untouched (quality-decision-8 rule); no RS entry carries a slur so the reference suite is unaffected. Test: an adjacent-event slur is penalized (>0), a wide-span slur is in-band (0). 935 tests, 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
586969125e
commit
7d61271069
|
|
@ -465,10 +465,8 @@ a `curve_count=0` line (a new tracked primitive kind), and the new
|
||||||
spanning a system break into per-system sub-cubics by de Casteljau. A curve's
|
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
|
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.
|
the system height for page overflow, like a volta bracket.
|
||||||
- `slur_shape_penalty` stops being *vacuous* 0.0 and becomes *0.0 by
|
- `slur_shape_penalty` stopped being *vacuous* 0.0 and became *0.0 by
|
||||||
construction*: the Minimal tier draws the ideal arc (or the authored
|
construction* at E2; Push 3 (below) makes it a **real measurement**.
|
||||||
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)
|
## ENGRAVER_VERSION 6 → 7: curve splitting across systems (Push 3, 2026-07-08)
|
||||||
|
|
||||||
|
|
@ -486,3 +484,26 @@ 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
|
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
|
`CurveFate::Rigid`, byte-identical to version 6, so the existing goldens are
|
||||||
unchanged (the fixture's slurs are short).
|
unchanged (the fixture's slurs are short).
|
||||||
|
|
||||||
|
## slur_shape_penalty measured, not pinned (Push 3, 2026-07-08)
|
||||||
|
|
||||||
|
The `slur_shape` quality axis moved off its `0.0` placeholder to a real
|
||||||
|
measurement per the Quality Metric Catalog (`req:qmc:slur`): for each drawn
|
||||||
|
slur `Curve` with chord `c > 0` (the segment between its endpoints) and apex
|
||||||
|
height `h` (max perpendicular distance from the curve to that chord, sampled at
|
||||||
|
`SLUR_APEX_SAMPLES = 32` points), the arc ratio `ρ = h/c` is penalized by its
|
||||||
|
distance outside the shallow-arc band `[0.08, 0.25]`
|
||||||
|
(`max(0, 0.08 − ρ, ρ − 0.25)`), meaned over curves and normalized by
|
||||||
|
`R_worst = 0.25`. The measurement is translation-invariant (chord and apex
|
||||||
|
shift together under casting), so measuring the post-cast curves is correct.
|
||||||
|
|
||||||
|
Honest outcome: the Minimal tier's mid-span slurs sit at `ρ = height/span =
|
||||||
|
0.16` (the `SLUR_HEIGHT_FACTOR`; in band → 0), but the fixed
|
||||||
|
`SLUR_MIN_HEIGHT`/`SLUR_MAX_HEIGHT` clamps push a **short** slur above the band
|
||||||
|
(a tall arc on a tiny chord, too bulgy) and a **very long** one below it (too
|
||||||
|
flat) — so the axis is genuinely non-zero for clamped spans, which a
|
||||||
|
duration-aware Standard-tier height would improve. A curve-free layout measures
|
||||||
|
0 by the vacuous-geometry rule. **No `ENGRAVER_VERSION` bump** — a
|
||||||
|
measurement-only change that leaves the resolved geometry, canonical bytes, and
|
||||||
|
render goldens untouched (the same rule quality decision 8 followed); the RS
|
||||||
|
reference suite is unaffected (no RS entry carries a slur).
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,9 @@
|
||||||
//! the private `quality` module computes all nine normative axes per the
|
//! the private `quality` module computes all nine normative axes per the
|
||||||
//! ratified *Quality Metric Catalog* companion (collision census, spacing
|
//! ratified *Quality Metric Catalog* companion (collision census, spacing
|
||||||
//! regularity, break/page/casting-off distribution, vertical gap deviation;
|
//! regularity, break/page/casting-off distribution, vertical gap deviation;
|
||||||
//! `slur_shape` is `0.0` **by construction** now that slurs draw — the Minimal
|
//! `slur_shape` is now **measured** — each drawn slur's arc ratio against the
|
||||||
//! tier emits the ideal arc, so a drawn slur has zero shape deviation — while
|
//! shallow-arc band — while `beam_slope` stays vacuous-`0.0` because no drawn
|
||||||
//! `beam_slope` stays vacuous-`0.0` because no drawn beam geometry exists yet),
|
//! beam geometry exists yet), normalized through the catalog's pinned anchors
|
||||||
//! normalized through the catalog's pinned anchors
|
|
||||||
//! ([`epiphany_layout_ir::quality`]), with
|
//! ([`epiphany_layout_ir::quality`]), with
|
||||||
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
|
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
|
||||||
//! threshold column the config's profile selects. The all-worst
|
//! threshold column the config's profile selects. The all-worst
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,13 @@
|
||||||
//! reference). The clef/key/time lead and barlines bear no notehead or rest,
|
//! reference). The clef/key/time lead and barlines bear no notehead or rest,
|
||||||
//! so they contribute no column and a note-to-note advance spans them
|
//! so they contribute no column and a note-to-note advance spans them
|
||||||
//! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture).
|
//! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture).
|
||||||
//! * **`slur_shape_penalty`** — **0.0 by construction** (E2): slurs now draw as
|
//! * **`slur_shape_penalty`** — **measured** (Push 3): each drawn slur curve's
|
||||||
//! cubic-bézier curves, but the Minimal tier emits the *reference* arc from
|
//! arc ratio `ρ = apex height / chord length` is penalized by its distance
|
||||||
//! the span and the authored/default curvature, so a drawn slur has zero
|
//! outside the shallow-arc band `[0.08, 0.25]` (catalog §`slur_shape`). The
|
||||||
//! deviation from its ideal (an authored `curvature_override` is honored, not
|
//! Minimal tier's mid-span slurs sit at `ρ ≈ 0.16` (in band, 0), but its
|
||||||
//! penalized). A real penalty awaits a Standard-tier solver that compromises
|
//! fixed height clamps push short slurs above the band (too bulgy) and very
|
||||||
//! a slur's shape to dodge collisions. **`beam_slope_penalty`** stays
|
//! long ones below it (too flat) — a real non-zero value. A curve-free layout
|
||||||
|
//! measures 0 by the vacuous-geometry rule. **`beam_slope_penalty`** stays
|
||||||
//! **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not
|
//! **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not
|
||||||
//! as segments), so its contributing-unit set is empty.
|
//! as segments), so its contributing-unit set is empty.
|
||||||
//! * **`vertical_density_penalty`** — realized gaps against the band model's
|
//! * **`vertical_density_penalty`** — realized gaps against the band model's
|
||||||
|
|
@ -92,6 +93,75 @@ fn mean_or_zero(values: &[f64]) -> f64 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many points a slur curve is sampled at to find its apex height. Even, so
|
||||||
|
/// a sample lands on the symmetric arc's `t = 0.5` apex exactly.
|
||||||
|
const SLUR_APEX_SAMPLES: usize = 32;
|
||||||
|
|
||||||
|
/// The raw `slur_shape_penalty` measurement (Quality Metric Catalog
|
||||||
|
/// §`slur_shape_penalty`): for each drawn slur curve with chord length `c > 0`
|
||||||
|
/// — the chord being the segment between the curve's endpoints and the apex
|
||||||
|
/// height `h` the maximum perpendicular distance from the curve to that chord —
|
||||||
|
/// the arc ratio `ρ = h / c` is penalized by its distance outside the shallow-
|
||||||
|
/// arc band `[0.08, 0.25]` (`max(0, 0.08 − ρ, ρ − 0.25)`); the axis raw value
|
||||||
|
/// is the arithmetic mean over units. A curve-free layout has no units, so the
|
||||||
|
/// mean is `0` (the vacuous-geometry rule) — not by construction but by
|
||||||
|
/// measurement.
|
||||||
|
fn slur_shape_raw(cast: &CastLayout) -> f64 {
|
||||||
|
let per_curve: Vec<f64> = cast
|
||||||
|
.curves
|
||||||
|
.iter()
|
||||||
|
.filter_map(|curve| {
|
||||||
|
let cp = curve.control_points();
|
||||||
|
let (a, b) = (point(cp[0]), point(cp[3]));
|
||||||
|
let chord = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||||
|
if chord <= 0.0 {
|
||||||
|
return None; // c > 0 required
|
||||||
|
}
|
||||||
|
let apex = (0..=SLUR_APEX_SAMPLES)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / SLUR_APEX_SAMPLES as f32;
|
||||||
|
perp_distance(a, b, cubic_point(cp, t))
|
||||||
|
})
|
||||||
|
.fold(0.0_f64, f64::max);
|
||||||
|
let rho = apex / chord;
|
||||||
|
Some((0.08 - rho).max(rho - 0.25).max(0.0))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
mean_or_zero(&per_curve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layout point as exact `f64`.
|
||||||
|
fn point(p: epiphany_layout_ir::Point) -> (f64, f64) {
|
||||||
|
(f64::from(p.x.0), f64::from(p.y.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cubic-Bézier point at parameter `t`, as `f64`.
|
||||||
|
fn cubic_point(cp: [epiphany_layout_ir::Point; 4], t: f32) -> (f64, f64) {
|
||||||
|
let (u, t) = (f64::from(1.0 - t), f64::from(t));
|
||||||
|
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||||
|
(
|
||||||
|
w[0] * f64::from(cp[0].x.0)
|
||||||
|
+ w[1] * f64::from(cp[1].x.0)
|
||||||
|
+ w[2] * f64::from(cp[2].x.0)
|
||||||
|
+ w[3] * f64::from(cp[3].x.0),
|
||||||
|
w[0] * f64::from(cp[0].y.0)
|
||||||
|
+ w[1] * f64::from(cp[1].y.0)
|
||||||
|
+ w[2] * f64::from(cp[2].y.0)
|
||||||
|
+ w[3] * f64::from(cp[3].y.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perpendicular distance from point `p` to the line through `a` and `b`
|
||||||
|
/// (0 when `a == b`).
|
||||||
|
fn perp_distance(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> f64 {
|
||||||
|
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||||
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
|
if len <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
((p.0 - a.0) * dy - (p.1 - a.1) * dx).abs() / len
|
||||||
|
}
|
||||||
|
|
||||||
/// One glyph's resolved ink box `[left, bottom, right, top]` (f64, exact from
|
/// One glyph's resolved ink box `[left, bottom, right, top]` (f64, exact from
|
||||||
/// the f32 geometry).
|
/// the f32 geometry).
|
||||||
fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] {
|
fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] {
|
||||||
|
|
@ -459,16 +529,15 @@ pub(crate) fn measure(
|
||||||
anchors::COLLISION_R_WORST,
|
anchors::COLLISION_R_WORST,
|
||||||
),
|
),
|
||||||
spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST),
|
spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST),
|
||||||
// Slurs now DRAW (E2: a cubic-bézier `Curve` per slur), but their shape
|
// Slurs draw (E2), so their shape is now MEASURED (Push 3), not pinned:
|
||||||
// is measured as ideal by construction: the Minimal tier emits the
|
// each drawn curve's arc ratio ρ = apex height / chord length is
|
||||||
// reference arc itself — a symmetric cubic from the span and the
|
// penalized by its distance outside the shallow-arc band [0.08, 0.25].
|
||||||
// authored (or default) curvature — so a drawn slur has zero deviation
|
// The Minimal tier's mid-span slurs sit at ρ ≈ 0.16 (in band, 0
|
||||||
// from its ideal, and an authored `curvature_override` is honored, not
|
// penalty), but its fixed height clamps push short slurs above the band
|
||||||
// penalized. A real non-zero penalty awaits a collision-aware
|
// (too bulgy) and very long ones below it (too flat) — a real, honest
|
||||||
// (Standard-tier, Push 3) solver that *compromises* a slur's shape to
|
// non-zero measurement. A curve-free layout measures 0 by the
|
||||||
// dodge collisions; until then this axis stays 0.0 by construction, not
|
// vacuous-geometry rule.
|
||||||
// by vacuity.
|
slur_shape_penalty: normalize(slur_shape_raw(cast), anchors::SLUR_SHAPE_R_WORST),
|
||||||
slur_shape_penalty: normalize(0.0, anchors::SLUR_SHAPE_R_WORST),
|
|
||||||
// Same vacuous rule: no drawn beam segments exist in this pipeline.
|
// Same vacuous rule: no drawn beam segments exist in this pipeline.
|
||||||
beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST),
|
beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST),
|
||||||
vertical_density_penalty: normalize(
|
vertical_density_penalty: normalize(
|
||||||
|
|
@ -584,9 +653,9 @@ mod tests {
|
||||||
let vector = &report.metric_vector;
|
let vector = &report.metric_vector;
|
||||||
assert_eq!(vector.collision_penalty.0, 0.0);
|
assert_eq!(vector.collision_penalty.0, 0.0);
|
||||||
assert!(vector.spacing_distortion.0 > 0.0 && vector.spacing_distortion.0 < 0.3);
|
assert!(vector.spacing_distortion.0 > 0.0 && vector.spacing_distortion.0 < 0.3);
|
||||||
// The ten-measure fixture has no slur; even one would measure 0.0
|
// The ten-measure fixture has no drawn slur curve, so the axis measures
|
||||||
// (Minimal draws the ideal arc — deviation is 0 by construction).
|
// 0.0 by the vacuous-geometry rule (an empty contributing-unit set).
|
||||||
assert_eq!(vector.slur_shape_penalty.0, 0.0, "ideal-by-construction");
|
assert_eq!(vector.slur_shape_penalty.0, 0.0, "vacuous: no drawn slurs");
|
||||||
assert_eq!(vector.beam_slope_penalty.0, 0.0, "vacuous: no drawn beams");
|
assert_eq!(vector.beam_slope_penalty.0, 0.0, "vacuous: no drawn beams");
|
||||||
assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page");
|
assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page");
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -618,6 +687,46 @@ mod tests {
|
||||||
assert_eq!(report.status, SolveStatus::Solved);
|
assert_eq!(report.status, SolveStatus::Solved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slur_shape_is_measured_penalizing_out_of_band_arcs() {
|
||||||
|
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle};
|
||||||
|
use epiphany_layout_ir::to_constrained;
|
||||||
|
|
||||||
|
let with_slur = |start: usize, end: usize| {
|
||||||
|
let mut s = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
|
||||||
|
let ev: Vec<_> = s.canvas.regions[0].staff_instances()[0].voices[0]
|
||||||
|
.events
|
||||||
|
.clone();
|
||||||
|
s.cross_cutting.slurs.push(Slur {
|
||||||
|
id: s.identity.mint::<SlurId>(),
|
||||||
|
start_event: ev[start],
|
||||||
|
end_event: ev[end],
|
||||||
|
kind: SlurKind::Legato,
|
||||||
|
curvature_override: None,
|
||||||
|
style: SpanStyle::default(),
|
||||||
|
});
|
||||||
|
Engraver::default()
|
||||||
|
.solve(&to_constrained(&to_logical(&s)), &SolverConfig::default())
|
||||||
|
.metric_vector
|
||||||
|
.slur_shape_penalty
|
||||||
|
.0
|
||||||
|
};
|
||||||
|
// A slur over adjacent events: the min-height clamp forces a tall arc
|
||||||
|
// over a tiny chord (ρ well above the 0.25 band), a real bulge penalty.
|
||||||
|
assert!(
|
||||||
|
with_slur(0, 1) > 0.0,
|
||||||
|
"a bulgy short slur is penalized: {}",
|
||||||
|
with_slur(0, 1)
|
||||||
|
);
|
||||||
|
// A slur over a wide span: the auto height gives ρ ≈ 0.16, inside the
|
||||||
|
// ideal band [0.08, 0.25], so no penalty.
|
||||||
|
assert_eq!(
|
||||||
|
with_slur(0, 6),
|
||||||
|
0.0,
|
||||||
|
"an in-band (mid-span) slur is not penalized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floor_warnings_reference_the_profiles_threshold_column() {
|
fn floor_warnings_reference_the_profiles_threshold_column() {
|
||||||
// The ten-measure fixture's casting-off distortion (~0.45: the
|
// The ten-measure fixture's casting-off distortion (~0.45: the
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue