diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index 4429e26..10cef7d 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -24,8 +24,10 @@ hard-constraint evaluation (which earned the `Minimal` tier). **Phase 3's layout track adds CASTING-OFF** (see "Casting-off (2026-07)" below): greedy system breaking at measure boundaries, vertical system stacking, page assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break- -constraint evaluation. Still deferred: the vertical soft-spring solve within a -system, per-system justification/stretch, and optimal break search. +constraint evaluation. **Push 3's Standard-tier track then adds per-system +JUSTIFICATION** (see "Per-system justification (Push 3)" below): every non-final +system stretches to fill the content width. Still deferred: the vertical +soft-spring solve within a system, and optimal (lookahead) break search. ### Honest tier @@ -197,10 +199,10 @@ inputs the solver cannot measure. `ENGRAVER_VERSION` 2 → 3 (a wrapping score's baked geometry differs from pure greedy); the `ten_measure` render goldens were regenerated. Still a Minimal heuristic, not an optimality claim. -10. **Deferred refinements** (named, not implied): per-system justification - (stretching the soft springs so every full system ends at the right - margin); the vertical spring solve (band heights are carried, not yet - renegotiated; systems stack by real content extents); orphan control and +10. **Deferred refinements** (named, not implied): ~~per-system justification~~ + (**landed in Push 3** — see below); the vertical spring solve (band heights + are carried, not yet renegotiated; systems stack by real content extents); + orphan control and optimal/lookahead casting-off quality beyond decision 9's tail-only widow rebalance — a `Standard`-tier concern (full-region rebalancing, and justification-aware casting-off once systems can stretch); casting-off caching / @@ -519,3 +521,54 @@ duration-aware Standard-tier height would improve. A curve-free layout measures 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). + +## Per-system justification (Push 3, 2026-07-08) + +The Standard-tier track's first block: every **non-final** system of a +multi-system region stretches its horizontal slack so its ink fills the content +width, instead of sitting at its natural left-aligned width. `ENGRAVER_VERSION` +7 → 8 (any wrapping score's baked geometry differs; a single-system score is +unchanged — its only system is ragged-right by convention). + +**The affine, clamped map.** Casting bakes each system by a `Placement`: a +vertical shift `dy` plus a horizontal map `world_x = a·x + b`. A rigid +(unjustified) system has `a = 1, b = dx` — the old pure translation. A justified +system spreads the slack linearly: `a = 1 + extra/span`, `b = base_dx − +extra·x0/span`, where `span = x_last − x0` is the slot-source range, `extra = +content_width − natural_ink_width`, and `base_dx` puts the leftmost ink at the +left margin. The map is **clamped** to `[x0, x1]`: within the slot range it is +affine; beyond it (a glyph's bearing overhang, a staff line drawn to the ink +edge) it is rigid slope-1 — so the mapped ink extremes agree *exactly* with the +per-slot deltas at the first/last slots, and the justified ink spans exactly +`[left_margin, left_margin + content_width]` (no over/undershoot). + +**Slot-relative, like E1.** Glyphs translate by the map evaluated at their +SLOT's source (`Placement::slot_dx`), constant per slot, so intra-slot offsets +(a time signature after its barline, an accidental left of its notehead) survive +verbatim — never scaled by `a`. A spanning stroke (staff line, volta bracket) +maps each endpoint through the affine (it stretches). A rigid-width stroke (a +stem or ledger, via `owning_glyph`) translates by its owning slot's delta, so it +stays attached without stretching. A slur's control points map straight through +the affine — its endpoints follow their anchor notes and the arc stretches with +the span. + +**Which systems justify.** Not the last system of a region (ragged-right, as +engraving convention wants); not a system with no finite width target, a +degenerate (single-slot) span, or already at/over width (`extra ≤ 0` — never +compressed into overlap). Greedy first-fit fills non-final systems near-full, so +the stretch factor `a` stays near 1 in practice. + +**Quality-metric consequences (honest, noted).** Justification drives +`system_break_penalty` to near zero (a non-final system now fills the width — the +point). As a side effect the width-uniformity axes RISE: the full non-final +system contrasts with the ragged last line, so `casting_off_quality` and +`symbol_density_uniformity` measure that contrast (for the ten-measure fixture, +casting-off ~0.45 → ~0.80). This is honest for justified layout (full lines + a +short last line), but the axes arguably penalize the *intentional* raggedness of +the final system. **Follow-up (not this tranche):** score casting-off on natural +(pre-justification) widths, or exclude / fill-fraction-weight the final system — +a metric-semantics change needing catalog alignment. **Also noted:** +`slur_shape` measures the *spaced* (pre-justification) whole curves, so +justification's horizontal stretch of a slur is not reflected — a second-order +gap in the same family as the spaced-vs-constrained one, a follow-up if slur +fidelity warrants measuring the post-justification whole curve. diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index 220f570..99f9830 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -81,10 +81,10 @@ use epiphany_core::{StaffId, TypedObjectId}; use epiphany_layout_ir::{ continuation_instance_key, is_barline_glyph, is_rigid_width_stroke, synthesized_layout_id, BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource, EngravingDecision, - EngravingDecisionKind, EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, - Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, - ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, - SynthesisRegistryId, VerticalBand, VerticalBandId, + EngravingDecisionKind, EngravingOverrideId, GlyphObject, GlyphObjectId, LayoutConstraint, + LayoutObjectId, Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, + ResolvedStaff, ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, + SynthesisKind, SynthesisRegistryId, VerticalBand, VerticalBandId, }; use crate::owning_glyph; @@ -279,6 +279,57 @@ enum CurveFate { Split(Vec<(usize, [Point; 4])>), } +/// A system's world-frame placement: a vertical shift `dy` plus a horizontal +/// affine map `world_x = a·x + b`. +/// +/// A rigid (unjustified) system has `a = 1`, `b = dx` — a pure translation. A +/// **justified** system has `a > 1`: the horizontal slack (content width minus +/// natural ink width) is spread linearly across the line so its ink fills the +/// content width. The map is applied SLOT-RELATIVELY to glyphs — each slot's +/// members translate by the map evaluated at the slot's source, so intra-slot +/// offsets (a time signature after its barline, an accidental left of its +/// notehead) survive verbatim — directly to spanning-stroke and curve +/// endpoints, and via the owning slot for a rigid-width stroke (a stem or ledger +/// that must stay attached to its notehead, not stretch). +#[derive(Copy, Clone)] +struct Placement { + a: f32, + b: f32, + dy: f32, + /// The system's slot-source range `[x0, x1]`. The affine stretch acts only + /// WITHIN it; beyond it (a glyph's bearing overhang, a staff line drawn to + /// the ink edge) the map is rigid slope-1, so the mapped ink extremes agree + /// exactly with the per-slot deltas at the first/last slots. + x0: f32, + x1: f32, +} + +impl Placement { + /// A pure translation (an unjustified system, or the identity fallback for + /// content no system claims). `a = 1`, so the clamp range is irrelevant. + fn rigid(dx: f32, dy: f32) -> Self { + Placement { + a: 1.0, + b: dx, + dy, + x0: 0.0, + x1: 0.0, + } + } + /// The world x of a spaced x: affine within the slot-source range, rigid + /// (slope 1) beyond it. + fn x(&self, x: f32) -> f32 { + let c = x.clamp(self.x0, self.x1); + self.a * c + self.b + (x - c) + } + /// The rigid delta every glyph in a slot whose source is `slot_x` + /// translates by — constant per slot, so intra-slot offsets are preserved. + /// Slot sources lie in `[x0, x1]`, so no clamp is needed. + fn slot_dx(&self, slot_x: f32) -> f32 { + (self.a - 1.0) * slot_x + self.b + } +} + /// The content extent of a system in spaced (pre-casting) coordinates. #[derive(Copy, Clone)] struct Extent { @@ -444,6 +495,12 @@ pub(crate) fn cast_off( for infos in &mut region_slots { infos.sort_by(|a, b| a.x.total_cmp(&b.x).then_with(|| a.id.cmp(&b.id))); } + // Each slot's spaced reference x, for the slot-relative justification delta. + let slot_source_x: BTreeMap = region_slots + .iter() + .flatten() + .map(|info| (info.id, info.x)) + .collect(); // ---- Break requirements ---------------------------------------------- let mut reqs: BTreeMap> = BTreeMap::new(); @@ -641,7 +698,7 @@ pub(crate) fn cast_off( .0; let content_height = geometry.content_height(); let bounded = content_height > 0.0; - let mut placements: Vec<(f32, f32)> = Vec::with_capacity(systems.len()); + let mut placements: Vec = Vec::with_capacity(systems.len()); let mut page_systems: Vec> = Vec::new(); let mut cursor = 0.0_f32; let mut page_floor = 0.0_f32; @@ -658,9 +715,17 @@ pub(crate) fn cast_off( page_floor = cursor - content_height.max(0.0); page_systems.push(Vec::new()); } - let dx = geometry.margins.left.0 - ext.min_x; + let base_dx = geometry.margins.left.0 - ext.min_x; let dy = cursor - ext.max_y; - placements.push((dx, dy)); + placements.push(justify_system( + plan, + ext, + base_dx, + dy, + ®ion_slots, + ®ion_systems, + width_limit, + )); page_systems .last_mut() .expect("a page was opened above") @@ -719,10 +784,18 @@ pub(crate) fn cast_off( .iter() .zip(&input.glyphs) .map(|(spaced, glyph)| { - let (dx, dy) = system_of_slot - .get(&glyph.horizontal_slot) - .map(|&s| placements[s]) - .unwrap_or((0.0, 0.0)); + let (dx, dy) = match system_of_slot.get(&glyph.horizontal_slot) { + Some(&s) => { + // Slot-relative: every member of a slot translates by the + // map at the slot's source, so intra-slot offsets survive. + let sx = slot_source_x + .get(&glyph.horizontal_slot) + .copied() + .unwrap_or(spaced.position.x.0); + (placements[s].slot_dx(sx), placements[s].dy) + } + None => (0.0, 0.0), + }; ResolvedGlyph { position: Point::new(spaced.position.x.0 + dx, spaced.position.y.0 + dy), ..spaced.clone() @@ -734,11 +807,19 @@ pub(crate) fn cast_off( let mut staff_marks: BTreeMap<(usize, StaffId), StaffAgg> = BTreeMap::new(); let mut strokes: Vec = Vec::with_capacity(spaced_strokes.len()); let mut continuations: Vec = Vec::new(); - for (spaced, fate) in spaced_strokes.iter().zip(&fates) { + for ((source, spaced), fate) in input.strokes.iter().zip(spaced_strokes).zip(&fates) { match fate { StrokeFate::Rigid(sys) => { - let (dx, dy) = sys.map(|s| placements[s]).unwrap_or((0.0, 0.0)); - let stroke = translated(spaced, dx, dy); + let stroke = match sys { + Some(s) => place_stroke( + source, + spaced, + placements[*s], + &slot_source_x, + &input.glyphs, + ), + None => spaced.clone(), + }; if let (Some(s), TypedObjectId::Staff(staff)) = (sys, spaced.provenance.source) { mark_staff(&mut staff_marks, *s, staff, &stroke); } @@ -746,7 +827,9 @@ pub(crate) fn cast_off( } StrokeFate::Split(segments) => { for (k, (s, from, to)) in segments.iter().enumerate() { - let (dx, dy) = placements[*s]; + // A split stroke spans systems — a staff line or volta + // bracket — so each segment stretches with its system. + let p = placements[*s]; let provenance = if k == 0 { // The first segment carries the original stroke's exact // provenance: the object survives, re-shaped. @@ -761,8 +844,8 @@ pub(crate) fn cast_off( }; let stroke = Stroke { provenance, - from: Point::new(from.x.0 + dx, from.y.0 + dy), - to: Point::new(to.x.0 + dx, to.y.0 + dy), + from: Point::new(p.x(from.x.0), from.y.0 + p.dy), + to: Point::new(p.x(to.x.0), to.y.0 + p.dy), thickness: spaced.thickness, layer: spaced.layer, style: spaced.style, @@ -791,12 +874,17 @@ pub(crate) fn cast_off( let mut curves: Vec = Vec::with_capacity(spaced_curves.len()); let mut curve_continuations: Vec = Vec::new(); for (curve, fate) in spaced_curves.iter().zip(&curve_fates) { + // A slur has no intra-slot structure, so its control points map straight + // through the affine: the endpoints follow their anchor notes (which sit + // at slot sources) and the arc stretches horizontally with the span. let shift = - |cp: [Point; 4], dx: f32, dy: f32| cp.map(|p| Point::new(p.x.0 + dx, p.y.0 + dy)); + |cp: [Point; 4], p: Placement| cp.map(|pt| Point::new(p.x(pt.x.0), pt.y.0 + p.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); + let p = system + .map(|s| placements[s]) + .unwrap_or(Placement::rigid(0.0, 0.0)); + let [p0, p1, p2, p3] = shift(curve.control_points(), p); curves.push(Curve { p0, p1, @@ -807,8 +895,7 @@ pub(crate) fn cast_off( } 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 [p0, p1, p2, p3] = shift(*cp, placements[*s]); let provenance = if k == 0 { curve.provenance.clone() } else { @@ -1475,6 +1562,78 @@ fn translated(stroke: &Stroke, dx: f32, dy: f32) -> Stroke { } } +/// A system's placement: rigid (translated to the left margin) unless the +/// system JUSTIFIES — a non-final system of its region, narrower than the +/// content width, with a positive slot span — in which case the horizontal +/// slack is spread linearly so the system's ink fills the content width (its +/// leftmost ink at the left margin, its rightmost at the right margin). A +/// region's last system stays ragged-right, as engraving convention wants; a +/// system already at or over width is not compressed into overlap. +fn justify_system( + plan: &SystemPlan, + ext: &Extent, + base_dx: f32, + dy: f32, + region_slots: &[Vec], + region_systems: &[Vec], + width_limit: f32, +) -> Placement { + let is_last = plan.local + 1 >= region_systems[plan.region].len(); + if is_last || !width_limit.is_finite() { + return Placement::rigid(base_dx, dy); + } + let slots = ®ion_slots[plan.region]; + let (Some(&first), Some(&last)) = (plan.slots.first(), plan.slots.last()) else { + return Placement::rigid(base_dx, dy); + }; + let x0 = slots[first].x; + let x1 = slots[last].x; + let span = x1 - x0; + let extra = width_limit - (ext.max_x - ext.min_x); + if span <= f32::EPSILON || extra <= f32::EPSILON { + return Placement::rigid(base_dx, dy); + } + // Within [x0, x1]: world_x(x) = x + base_dx + extra·(x − x0)/span, i.e. + // a·x + b. Beyond it, `Placement::x` falls back to rigid slope 1. + Placement { + a: 1.0 + extra / span, + b: base_dx - extra * x0 / span, + dy, + x0, + x1, + } +} + +/// Places a whole stroke under a system's justification. A rigid-width stroke +/// (a stem or ledger) tracks its notehead: both endpoints translate by the +/// owning slot's delta, so it stays attached without stretching. A spanning +/// stroke (a staff line, a volta bracket) stretches with the system: each +/// endpoint maps through the affine. +fn place_stroke( + source: &Stroke, + spaced: &Stroke, + p: Placement, + slot_source_x: &BTreeMap, + glyphs: &[GlyphObject], +) -> Stroke { + if is_rigid_width_stroke(source) { + if let Some(dx) = owning_glyph(source, glyphs) + .and_then(|g| slot_source_x.get(&g.horizontal_slot)) + .map(|&sx| p.slot_dx(sx)) + { + return translated(spaced, dx, p.dy); + } + } + Stroke { + provenance: spaced.provenance.clone(), + from: Point::new(p.x(spaced.from.x.0), spaced.from.y.0 + p.dy), + to: Point::new(p.x(spaced.to.x.0), spaced.to.y.0 + p.dy), + thickness: spaced.thickness, + layer: spaced.layer, + style: spaced.style, + } +} + /// Accumulated staff-line geometry within one system, for the resolved staff /// record: the extent of the staff's line segments and the provenance of its /// bottom line (the segment that anchors the staff in this system). @@ -1534,11 +1693,11 @@ fn build_system( input: &ConstrainedLayoutIR, region_slots: &[Vec], extents: &[Extent], - placements: &[(f32, f32)], + placements: &[Placement], staff_marks: &BTreeMap<(usize, StaffId), StaffAgg>, ) -> ResolvedSystem { let region = &input.regions[plan.region]; - let (dx, dy) = placements[system]; + let p = placements[system]; let ext = &extents[system]; let provenance = if plan.local == 0 { region.provenance.clone() @@ -1554,9 +1713,12 @@ fn build_system( ) }; let bounding_box = Rect { - origin: Point::new(ext.min_x + dx, ext.min_y + dy), + // Justification stretches the horizontal extent: the box spans the + // system's world-frame ink, which for a justified system is the content + // width. + origin: Point::new(p.x(ext.min_x), ext.min_y + p.dy), size: Size2D { - width: StaffSpace(ext.max_x - ext.min_x), + width: StaffSpace(p.x(ext.max_x) - p.x(ext.min_x)), height: StaffSpace(ext.max_y - ext.min_y), }, }; @@ -1607,9 +1769,9 @@ fn build_system( provenance: glyph.provenance.clone(), measure, bounding_box: Rect { - origin: Point::new(start + dx, ext.min_y + dy), + origin: Point::new(p.x(start), ext.min_y + p.dy), size: Size2D { - width: StaffSpace(end - start), + width: StaffSpace(p.x(end) - p.x(start)), height: StaffSpace(ext.max_y - ext.min_y), }, }, @@ -1757,20 +1919,24 @@ mod tests { use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig}; // The ten-measure fixture wraps into two systems under the default A4 // geometry. Greedy first-fit alone leaves a two-measure stub final - // system (its width barely a quarter of the first's); the widow - // rebalance evens the split so the final system is a substantial - // fraction of its predecessor — while the system *count* is unchanged. + // system; the widow rebalance evens the split so the final system + // carries a substantial share of the measures — while the system + // *count* is unchanged. (Justification now stretches every non-final + // system to the full content width, so the rebalance's effect shows in + // the MEASURE distribution, not the baked widths — the non-final system + // fills the width regardless.) let input = to_constrained(&to_logical( &epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE), )); let report = Engraver::default().solve(&input, &SolverConfig::default()); let page = &report.layout.pages[0]; assert_eq!(page.systems.len(), 2, "the fixture wraps into two systems"); - let first = page.systems[0].bounding_box.size.width.0; - let last = page.systems[1].bounding_box.size.width.0; + let first = page.systems[0].measures.len(); + let last = page.systems[1].measures.len(); assert!( - last > 0.5 * first, - "the rebalanced final system is not a stub: {last} vs {first}" + last * 2 >= first, + "the rebalanced final system carries a substantial share of the \ + measures, not a stub: {last} vs {first}" ); } diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index b26f500..a92b467 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -137,8 +137,13 @@ pub struct Engraver { /// 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); +/// one system is unchanged, and to `8` when **per-system justification** landed +/// (every non-final system of a multi-system region stretches its horizontal +/// slack so its ink fills the content width, instead of sitting at its natural +/// left-aligned width; the baked geometry of any wrapping score differs, while a +/// single-system score — whose only system is ragged-right by convention — is +/// unchanged). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(8); impl Engraver { /// An engraver casting off against the given page geometry. diff --git a/crates/epiphany-engrave/src/quality.rs b/crates/epiphany-engrave/src/quality.rs index ad778ee..1d9646e 100644 --- a/crates/epiphany-engrave/src/quality.rs +++ b/crates/epiphany-engrave/src/quality.rs @@ -673,32 +673,42 @@ mod tests { 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.page_fill_efficiency.0, 0.0, "vacuous: single page"); + // Per-system justification fills every NON-final system to the content + // width, so the system-break axis (which measures a non-final system's + // shortfall) collapses to near zero — the point of justification. assert!( - (0.55..0.85).contains(&vector.system_break_penalty.0), - "the non-final system is evened below full: {}", + vector.system_break_penalty.0 < 0.1, + "the non-final system fills the width after justification: {}", vector.system_break_penalty.0 ); + // The width-uniformity axes RISE as a consequence: the justified + // non-final system now spans the full width while the last stays + // ragged-right, so the two systems' ink widths (and their glyph + // densities) differ more than the rebalanced natural widths did. This + // is honest for a two-system score (one full line, one short last + // line); a metric refinement that scores casting-off on natural widths + // or excludes the intentionally-ragged final system is a follow-up (see + // DECISIONS). assert!( - (0.3..0.6).contains(&vector.casting_off_quality.0), - "the rebalanced split is even, not a clamped-worst stub: {}", + (0.7..0.9).contains(&vector.casting_off_quality.0), + "the full non-final system contrasts with the ragged last: {}", vector.casting_off_quality.0 ); assert!( - vector.symbol_density_uniformity.0 < 0.1, - "density is even though widths are not: {}", + (0.3..0.5).contains(&vector.symbol_density_uniformity.0), + "density differs between the stretched and the ragged system: {}", vector.symbol_density_uniformity.0 ); - // Both the casting-off and the system-break axes exceed 0.8 x their - // Standard threshold, so the SHOULD-level floor diagnostic fires for - // each — the two sides of the rebalance trade-off, honestly reported — - // and, per the catalog, the status is untouched by them. + // The casting-off axis exceeds 0.8 × its Standard threshold, so its + // SHOULD-level floor diagnostic fires (per the catalog, never changing + // status); the system-break axis, now near zero, no longer floors. let floored = |metric: QualityMetricKind| { report.warnings.iter().any(|w| { matches!(w.kind, SolverWarningKind::QualityFloorApproached { metric: m } if m == metric) }) }; assert!(floored(QualityMetricKind::CastingOff)); - assert!(floored(QualityMetricKind::SystemBreak)); + assert!(!floored(QualityMetricKind::SystemBreak)); assert_eq!(report.status, SolveStatus::Solved); } @@ -788,24 +798,34 @@ mod tests { #[test] fn floor_warnings_reference_the_profiles_threshold_column() { - // The ten-measure fixture's casting-off distortion (~0.45: the - // six/four widow-rebalanced split) sits between the Standard column's - // floor (0.8 x 0.35 = 0.28) and the Minimal column's (0.8 x 0.90 = - // 0.72) — so the default Standard profile warns about CastingOff and - // the Draft profile (which selects the Minimal column per the catalog's - // profile registry) does not. (This is the profile-column contrast the - // b-flat scale's spacing used to show, before spacing_distortion was - // scoped to rhythmic columns and short scores stopped warning; the - // casting-off axis carries the demonstration now.) - let input = ten_measure(); + // The floor warning references the PROFILE'S threshold column, not a + // fixed threshold: the Standard column's casting-off threshold is + // tighter than the Minimal column's, so a value BETWEEN their floors + // warns under Standard/Publication (the Standard column) but not under + // Draft (the Minimal column). Tested directly on `floor_warnings` with a + // synthetic value between the two floors — robust to how any particular + // fixture's casting-off happens to land (justification, for one, now + // drives the ten-measure fixture's casting-off above both floors). + use epiphany_layout_ir::quality::{ + profile_thresholds, MINIMAL_THRESHOLDS, QUALITY_FLOOR_FRACTION, STANDARD_THRESHOLDS, + }; + use epiphany_layout_ir::NormalizedMetric; + + let standard_floor = QUALITY_FLOOR_FRACTION * STANDARD_THRESHOLDS.casting_off_quality; + let minimal_floor = QUALITY_FLOOR_FRACTION * MINIMAL_THRESHOLDS.casting_off_quality; + assert!( + standard_floor < minimal_floor, + "the Standard casting-off column is tighter than the Minimal one" + ); + let between = 0.5 * (standard_floor + minimal_floor); + // Only casting_off matters here; the other axes' values are irrelevant + // (the closure inspects the CastingOff warning alone). + let vector = QualityMetricVector { + casting_off_quality: NormalizedMetric(between), + ..QualityMetricVector::unmeasured() + }; let castoff_warned = |profile: SolverProfile| { - let config = SolverConfig { - profile, - ..SolverConfig::default() - }; - Engraver::default() - .solve(&input, &config) - .warnings + super::floor_warnings(&vector, profile_thresholds(profile)) .iter() .any(|w| { matches!( @@ -819,14 +839,6 @@ mod tests { assert!(castoff_warned(SolverProfile::Standard)); assert!(castoff_warned(SolverProfile::Publication)); assert!(!castoff_warned(SolverProfile::Draft)); - // The metric itself is profile-independent — only the diagnostic - // column changes. - let value = Engraver::default() - .solve(&input, &SolverConfig::default()) - .metric_vector - .casting_off_quality - .0; - assert!((0.28..=0.72).contains(&value), "casting_off = {value}"); } #[test] diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt index 86ad279..da58de3 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt @@ -8,7 +8,7 @@ provenance_count=147 layer_count=1 hard_constraint_count=90 xml_well_formed=true -view_box=[5.4999986 -26.090391 64.94533 20.589813] +view_box=[5.4999986 -26.090391 94.000015 20.589813] class_counts: barline=10 clef=1 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg index 04e3d96..e5d4f22 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg @@ -1,63 +1,63 @@ - + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -100,30 +100,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -140,12 +140,12 @@ - - - - - - + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt index d647a4b..4aa434d 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt @@ -8,7 +8,7 @@ provenance_count=161 layer_count=1 hard_constraint_count=95 xml_well_formed=true -view_box=[5.4999995 -28.60539 67.03889 23.104813] +view_box=[5.4999995 -28.60539 94.000015 23.104813] class_counts: barline=7 clef=1 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg index 3fd96ec..e12a30b 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg @@ -1,63 +1,63 @@ - + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -109,30 +109,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -149,12 +149,12 @@ - - - - - - + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt index 3a51a2f..27336bc 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt @@ -8,7 +8,7 @@ provenance_count=150 layer_count=1 hard_constraint_count=90 xml_well_formed=true -view_box=[5.4999986 -28.318335 64.94533 22.818335] +view_box=[5.4999986 -28.318335 94.000015 22.818335] class_counts: barline=10 clef=1 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg index bb122cd..12ab45d 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg @@ -1,63 +1,63 @@ - + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -99,34 +99,34 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -143,12 +143,12 @@ - - - - - - + + + + + +