From c5173d275e939654def43e8ef37fd7d1dda08b88 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 17:13:32 -0400 Subject: [PATCH] Push 3 (slur quality) 1/3: render dashed/dotted slurs faithfully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2 drew every slur solid and surfaced a SlurLineStyleNotRendered diagnostic for a non-Solid authored line. That deferral is now lifted: the Curve primitive gains a `line: LineStyle` (from the slur's SpanStyle.line), threaded through the resolved canonical encode (a per-curve style byte), the engrave remap, and casting; render-svg emits stroke-dasharray (dashed = "0.5 0.35"; dotted = round-capped "0 0.28"). The diagnostic variant is removed — the style is rendered, not deferred, so `req:layoutir:slur-curve`'s "never silently rendered solid" is satisfied by faithful rendering rather than a surfaced gap. ENGRAVER_VERSION 5→6 (a dashed/dotted slur's resolved bytes and SVG differ). Zero churn beyond the one dashed slur: the slur fixture's two solid curves are byte-identical; only the editorial slur's gained the dasharray. Solid slurs and slur-free scores are unchanged. 932 tests, conformance 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-engrave/src/lib.rs | 8 ++- crates/epiphany-layout-ir/src/constrained.rs | 64 ++++++++++--------- crates/epiphany-layout-ir/src/hittest.rs | 1 + crates/epiphany-layout-ir/src/lib.rs | 1 + crates/epiphany-layout-ir/src/resolved.rs | 5 ++ crates/epiphany-render-svg/src/svg.rs | 57 ++++++++++++++++- .../golden/ten_measure_with_slurs.engrave.svg | 2 +- .../golden/ten_measure_with_slurs.stub.svg | 2 +- crates/epiphany-testkit/src/fixtures.rs | 6 +- crates/epiphany-testkit/src/layout_stub.rs | 5 ++ 10 files changed, 110 insertions(+), 41 deletions(-) diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 9b6c0e6..1ba7bb6 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -132,8 +132,11 @@ pub struct Engraver { /// unchanged), and to `5` when slur curves landed (a slur-bearing score gains /// a drawn cubic-bézier curve where version `4` had only a traced anchor; the /// resolved output now carries a third primitive kind, so its canonical bytes -/// differ; slur-free scores draw the same ink as before). -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(5); +/// 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); impl Engraver { /// An engraver casting off against the given page geometry. @@ -500,6 +503,7 @@ impl HorizontalRemap { thickness: c.thickness, layer: c.layer, style: c.style, + line: c.line, } }) .collect() diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 03b4fde..7a37298 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -112,6 +112,9 @@ pub struct Curve { pub thickness: StaffSpace, pub layer: i32, pub style: GlyphStyle, + /// The line pattern the renderer strokes the path with (a slur's authored + /// `SpanStyle.line`). Solid, dashed, or dotted. + pub line: LineStyle, } impl Curve { @@ -176,11 +179,6 @@ pub enum LayoutDiagnosticKind { /// sixteenth-or-shorter rest); the object is carried as a traced anchor /// rather than drawn at a guessed shape. UnbundledGlyph(GlyphReference), - /// A slur carries an authored non-`Solid` line style (dashed/dotted) that - /// the Minimal tier does not render — the curve is drawn solid, and the - /// unrendered style is surfaced here rather than silently dropped (its dash - /// pattern is a higher-tier refinement). - SlurLineStyleNotRendered, } #[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] @@ -1476,20 +1474,7 @@ pub fn try_to_constrained( _ => None, }; match curve { - Some(curve) => { - // A drawn slur whose authored line style is not solid - // renders solid at this tier; surface the gap so the - // dash/dotted intent is not silently lost. - if let Some(LayoutContent::Slur(slur)) = content { - if slur.line != LineStyle::Solid { - emit.diag( - provenance.source, - LayoutDiagnosticKind::SlurLineStyleNotRendered, - ); - } - } - emit.curve(curve); - } + Some(curve) => emit.curve(curve), None => emit.stroke(anchor(provenance, Point::new(default_x, yo))), } } @@ -2222,6 +2207,9 @@ fn slur_curve( thickness: StaffSpace(thickness), layer: 0, style: ink(), + // The authored line pattern is rendered faithfully (dashed/dotted), + // not deferred — the renderer strokes the path with it. + line: slur.line, }) } @@ -4436,12 +4424,17 @@ mod tests { } #[test] - fn an_authored_dashed_slur_draws_solid_but_surfaces_a_diagnostic() { + fn an_authored_dashed_slur_renders_a_dashed_curve() { use epiphany_core::{LineStyle, SpanStyle}; let (mut score, _) = repeat_ready_score(45); let events = region_a_events(&score); - let id: SlurId = score.identity.mint(); - let mut dashed = slur(id, events[0], events[2], None); + let solid_id: SlurId = score.identity.mint(); + let dashed_id: SlurId = score.identity.mint(); + score + .cross_cutting + .slurs + .push(slur(solid_id, events[0], events[2], None)); + let mut dashed = slur(dashed_id, events[0], events[2], None); dashed.style = SpanStyle { line: LineStyle::Dashed, thickness: None, @@ -4449,16 +4442,25 @@ mod tests { score.cross_cutting.slurs.push(dashed); let constrained = to_constrained(&to_logical(&score)); - // The curve still draws (solid) — ink and provenance preserved. - assert!(slur_curve_of(&constrained, id).is_some()); - // …and the unrendered dash style is surfaced, not silently dropped. - assert!( - constrained.diagnostics.iter().any(|d| { - d.source == TypedObjectId::Slur(id) - && d.kind == LayoutDiagnosticKind::SlurLineStyleNotRendered - }), - "a dashed slur surfaces a SlurLineStyleNotRendered diagnostic" + // The authored line pattern is carried on the drawn curve — rendered + // faithfully, not deferred to a diagnostic. + assert_eq!( + slur_curve_of(&constrained, dashed_id) + .expect("dashed slur draws") + .line, + LineStyle::Dashed ); + assert_eq!( + slur_curve_of(&constrained, solid_id) + .expect("solid slur draws") + .line, + LineStyle::Solid + ); + // No line-style diagnostic remains — the style is rendered, not surfaced. + assert!(constrained + .diagnostics + .iter() + .all(|d| d.source != TypedObjectId::Slur(dashed_id))); } #[test] diff --git a/crates/epiphany-layout-ir/src/hittest.rs b/crates/epiphany-layout-ir/src/hittest.rs index 15832b4..2cc8f76 100644 --- a/crates/epiphany-layout-ir/src/hittest.rs +++ b/crates/epiphany-layout-ir/src/hittest.rs @@ -638,6 +638,7 @@ mod tests { thickness: crate::StaffSpace(0.2), layer: 0, style: crate::GlyphStyle { rgba: 0 }, + line: epiphany_core::LineStyle::Solid, }; let map = RenderIR { primitives: vec![], diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index cd1fcea..d5a1e7e 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -164,4 +164,5 @@ pub use vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId, Vertic pub use epiphany_ops::OperationKindTag; // `StemDirection` (Agent B) is the payload of `EngravingDecisionKind::StemDirection`; // re-exported so callers constructing that decision need not also import from core. +pub use epiphany_core::LineStyle; pub use epiphany_core::StemDirection; diff --git a/crates/epiphany-layout-ir/src/resolved.rs b/crates/epiphany-layout-ir/src/resolved.rs index e07b563..7497fee 100644 --- a/crates/epiphany-layout-ir/src/resolved.rs +++ b/crates/epiphany-layout-ir/src/resolved.rs @@ -172,6 +172,11 @@ impl CanonicalEncode for ResolvedLayoutIR { encode_staff_space(out, curve.thickness); out.extend_from_slice(&curve.style.rgba.to_le_bytes()); out.extend_from_slice(&curve.layer.to_le_bytes()); + out.push(match curve.line { + epiphany_core::LineStyle::Solid => 0, + epiphany_core::LineStyle::Dashed => 1, + epiphany_core::LineStyle::Dotted => 2, + }); } push_len(out, self.engraving_decisions.len()); for decision in &self.engraving_decisions { diff --git a/crates/epiphany-render-svg/src/svg.rs b/crates/epiphany-render-svg/src/svg.rs index ee44309..8f74de9 100644 --- a/crates/epiphany-render-svg/src/svg.rs +++ b/crates/epiphany-render-svg/src/svg.rs @@ -39,7 +39,9 @@ use std::collections::BTreeMap; use std::fmt::Write as _; -use epiphany_layout_ir::{BoundingBox, Provenance, ResolvedGlyph, ResolvedLayoutIR, Transform2D}; +use epiphany_layout_ir::{ + BoundingBox, LineStyle, Provenance, ResolvedGlyph, ResolvedLayoutIR, Transform2D, +}; use crate::font_subset_generated::{ BRAVURA_SUBSET_FAMILY, BRAVURA_SUBSET_MIME, BRAVURA_SUBSET_OTF_BASE64, @@ -370,7 +372,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut let _ = writeln!( s, " ", + fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}{}{}/>", num(curve.p0.x.0), num(curve.p0.y.0), num(curve.p1.x.0), @@ -381,6 +383,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut num(curve.p3.y.0), stroke_fill, num(curve.thickness.0), + dash_attrs(curve.line), opacity, prov, ); @@ -600,6 +603,18 @@ fn stroke_provenance_attrs(p: &Provenance) -> String { ) } +/// The `stroke-dasharray` (and, for dotted, `stroke-linecap`) attribute for a +/// curve's line pattern, in staff-space units (the viewBox is staff-space). A +/// solid line adds nothing. Dashed is a dash/gap pair; dotted is round-capped +/// zero-length dashes, drawing round dots of the stroke's own width. +fn dash_attrs(line: LineStyle) -> &'static str { + match line { + LineStyle::Solid => "", + LineStyle::Dashed => " stroke-dasharray=\"0.5 0.35\"", + LineStyle::Dotted => " stroke-dasharray=\"0 0.28\" stroke-linecap=\"round\"", + } +} + /// `data-*` provenance attributes for a curve (a cubic-bézier primitive). fn curve_provenance_attrs(p: &Provenance) -> String { format!( @@ -812,6 +827,44 @@ mod tests { ); } + #[test] + fn a_curve_renders_as_a_path_and_a_dashed_curve_carries_a_dasharray() { + use epiphany_layout_ir::Curve; + let mut layout = stub_layout(11); + let prov = layout.glyphs[0].provenance.clone(); + let make = |line| Curve { + provenance: prov.clone(), + p0: Point::new(0.0, 0.0), + p1: Point::new(1.0, 2.0), + p2: Point::new(3.0, 2.0), + p3: Point::new(4.0, 0.0), + thickness: StaffSpace(0.12), + layer: 1, + style: GlyphStyle { rgba: 0x0000_00ff }, + line, + }; + // A solid curve: a stroked, unfilled with no dasharray. + layout.curves = vec![make(LineStyle::Solid)]; + let solid = render(&layout, &RenderOptions::default()); + assert!(solid.is_well_formed()); + assert_eq!(solid.stats.curve_count, 1); + assert!(solid.svg.contains(" - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg index b3c6e0e..c93373c 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg @@ -96,7 +96,7 @@ - + diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index f554c89..ac55988 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -296,10 +296,8 @@ pub fn ten_measure_with_slurs(seed: u64) -> Score { end_event: events[12], kind: SlurKind::Editorial, curvature_override: None, - // An authored dashed line — the Minimal tier draws it solid and - // surfaces a `SlurLineStyleNotRendered` layout diagnostic (its dash - // pattern is a higher-tier refinement), so the fixture exercises that - // honest-deferral path. + // An authored dashed line — rendered faithfully as a dashed cubic + // (`stroke-dasharray`), so the fixture exercises the line-pattern path. style: SpanStyle { line: LineStyle::Dashed, thickness: None, diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 204d29a..1e4f3e7 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -359,6 +359,11 @@ pub fn gen_curve(rng: &mut Rng) -> Curve { style: GlyphStyle { rgba: rng.next_u64() as u32, }, + line: *rng.choose(&[ + epiphany_core::LineStyle::Solid, + epiphany_core::LineStyle::Dashed, + epiphany_core::LineStyle::Dotted, + ]), } }