Schema major 2 Phase E2: slur curves + cubic-bézier primitive

The third pipeline primitive kind, review-hardened (5 verified findings fixed
pre-commit). A `Curve` (four control points, mirroring `Stroke`) threads
through all three IR stages, the canonical-encode fingerprint (a 5th u32 count
prefix; width-lock 4→5), the round-trip provenance chains and count identity,
`to_render`, the stub solver, engrave remap + casting, SVG path emission, and
hit-testing.

Slurs draw as one cubic bézier per slur carrying the slur's exact provenance
(no synthesis). LayoutContent::Slur resolves each endpoint event to a Note
column at to_logical (SlurEndpoint At/Unresolved — E1's honest-placement
discipline); a symmetric arc whose apex sits `height` from the endpoint line;
curvature_override direction+height honored, style.line (dashed) deferred to
Push 3. Honest non-drawing (traced anchor kept) for an unresolved endpoint, a
non-left-to-right span, or a cross-staff slur (no single staff — would float
at yo=0). Authored height/thickness sanitized to defaults when non-positive
(a negative thickness would else fail validation and blank the layout).

Hit-test: HitShape::Curve (Copy) flattens the cubic to a 16-segment capsule
inside contains/intersects (one region per curve); a slur click resolves to
Slur generically (no editor arm; edit ops refuse it). render-svg: stroked
unfilled <path C> after strokes/before glyphs + curve_count + content-hull
bounds. engrave: HorizontalRemap::curves; casting curve_system = Rigid-to-
start-system (no de Casteljau split — deferred; break-spanning slur draws
whole in its start system, kept for the source surjection); ENGRAVER_VERSION
4→5; slur_shape_penalty now 0.0 by construction (Minimal draws the ideal arc).

Existing SVG goldens changed only in the provenance-note comment (curves now
enumerated) — geometry byte-identical; 6 snapshots gained curve_count; new
ten_measure_with_slurs goldens. 929 tests, conformance 8/8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 15:33:00 -04:00
parent 6651ae5fce
commit 81b7f42f0a
36 changed files with 1580 additions and 93 deletions

View File

@ -2646,7 +2646,7 @@ mod tests {
(b.left.0 + b.right.0) / 2.0, (b.left.0 + b.right.0) / 2.0,
(b.bottom.0 + b.top.0) / 2.0, (b.bottom.0 + b.top.0) / 2.0,
)), )),
HitShape::Segment { .. } => None, HitShape::Segment { .. } | HitShape::Curve { .. } => None,
}) })
.expect("the rich fixture renders a notehead"); .expect("the rich fixture renders a notehead");
session.click(click).expect("the click selects a glyph") session.click(click).expect("the click selects a glyph")
@ -2664,6 +2664,57 @@ mod tests {
session.select(lo).expect("selects the pitch") session.select(lo).expect("selects the pitch")
} }
/// Clicking a slur curve selects the slur (schema-major-2 E2). A slur draws
/// a cubic-bézier `Curve` primitive whose hit region flows through
/// `click()` generically — no slur-specific arm — so the selection resolves
/// to `TypedObjectId::Slur`. A click on the *arc* (its `t = 0.5` apex, not
/// its chord) hits it, proving the flattened-capsule hit shape.
#[test]
fn clicking_a_slur_curve_selects_the_slur() {
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle};
let mut score = valid_score_rich(0x5EED);
// Two events of region A's first voice, on its one staff.
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[2],
kind: SlurKind::Legato,
curvature_override: None,
style: SpanStyle::default(),
});
let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
// The slur's curve region, and the arc's apex (cubic at t = 0.5).
let apex = session
.hit_test()
.regions
.iter()
.find_map(|r| match r.shape {
HitShape::Curve { p0, p1, p2, p3, .. }
if r.source == TypedObjectId::Slur(slur_id) =>
{
let mid = |a: f32, b: f32, c: f32, d: f32| (a + 3.0 * b + 3.0 * c + d) / 8.0;
Some(Point::new(
mid(p0.x.0, p1.x.0, p2.x.0, p3.x.0),
mid(p0.y.0, p1.y.0, p2.y.0, p3.y.0),
))
}
_ => None,
})
.expect("the slur draws a curve region");
let selection = session.click(apex).expect("the click selects the slur");
assert_eq!(selection.source, TypedObjectId::Slur(slur_id));
// A slur is not an editable target: an edit op cleanly refuses it
// rather than mishandling the non-pitch selection.
assert!(session.transpose_selection(1).is_err());
}
/// A pitch in the last event of the first voice that has events — its slot has /// A pitch in the last event of the first voice that has events — its slot has
/// room after it (nothing follows in that voice), so an insert-after applies. /// room after it (nothing follows in that voice), so an insert-after applies.
fn last_event_pitch(session: &EditorSession) -> PitchId { fn last_event_pitch(session: &EditorSession) -> PitchId {

View File

@ -108,6 +108,18 @@ fn shape_rect(shape: &HitShape, vm: &ViewMap) -> egui::Rect {
vm.world_to_screen(from.x.0, from.y.0), vm.world_to_screen(from.x.0, from.y.0),
vm.world_to_screen(to.x.0, to.y.0), vm.world_to_screen(to.x.0, to.y.0),
), ),
// A curve's highlight rectangle is its control-point hull (the box that
// bounds the drawn arc), mapped to screen space.
HitShape::Curve { p0, p1, p2, p3, .. } => {
let xs = [p0.x.0, p1.x.0, p2.x.0, p3.x.0];
let ys = [p0.y.0, p1.y.0, p2.y.0, p3.y.0];
let min = |a: &[f32]| a.iter().copied().fold(f32::INFINITY, f32::min);
let max = |a: &[f32]| a.iter().copied().fold(f32::NEG_INFINITY, f32::max);
egui::Rect::from_two_pos(
vm.world_to_screen(min(&xs), max(&ys)),
vm.world_to_screen(max(&xs), min(&ys)),
)
}
} }
} }

View File

@ -445,3 +445,30 @@ Repeat-free scores are byte-identical (the existing `ten_measure` /
`time_signature_digits_ride_their_barline_slot_past_a_repeat_sign` `time_signature_digits_ride_their_barline_slot_past_a_repeat_sign`
(unbounded page so x-disjointness compares one line; verified to fail (unbounded page so x-disjointness compares one line; verified to fail
against the interpolated remap). against the interpolated remap).
## ENGRAVER_VERSION 4 → 5: slur curves (E2, 2026-07-08)
The pipeline draws slurs as cubic-bézier `Curve` primitives (layout-ir E2), so
the resolved output carries a third primitive kind and a slur-bearing score's
baked geometry differs from version 4's traced anchors — a version bump per
the constant's own rule. Slur-free scores draw the same ink; only the empty
`curves` count prefix enters their canonical bytes (self-consistent). The
existing render SVG goldens are byte-identical; the six snapshot goldens gained
a `curve_count=0` line (a new tracked primitive kind), and the new
`ten_measure_with_slurs` fixture got its own goldens. Engrave changes:
- `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.
- `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.

View File

@ -80,7 +80,7 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{StaffId, TypedObjectId}; use epiphany_core::{StaffId, TypedObjectId};
use epiphany_layout_ir::{ use epiphany_layout_ir::{
continuation_instance_key, is_barline_glyph, is_rigid_width_stroke, synthesized_layout_id, continuation_instance_key, is_barline_glyph, is_rigid_width_stroke, synthesized_layout_id,
BreakClass, BreakKind, ConstrainedLayoutIR, DecisionSource, EngravingDecision, BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource, EngravingDecision,
EngravingDecisionKind, EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, EngravingDecisionKind, EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId,
Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff,
ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind,
@ -179,6 +179,10 @@ pub(crate) struct CastLayout {
/// system; a system-spanning stroke replaced by its first segment), then /// system; a system-spanning stroke replaced by its first segment), then
/// the synthesized continuation segments. /// the synthesized continuation segments.
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
/// Final curves, in input order, each translated with its system. A curve
/// spanning a system break is drawn whole in its start system (Minimal
/// boundary: an honest cubic split needs de Casteljau, deferred).
pub curves: Vec<Curve>,
/// The populated page tree (empty when the input declares no regions). /// The populated page tree (empty when the input declares no regions).
pub pages: Vec<ResolvedPage>, pub pages: Vec<ResolvedPage>,
/// Break decisions this pass made (chosen breaks in reading order, then /// Break decisions this pass made (chosen breaks in reading order, then
@ -342,6 +346,7 @@ pub(crate) fn cast_off(
input: &ConstrainedLayoutIR, input: &ConstrainedLayoutIR,
spaced_glyphs: &[ResolvedGlyph], spaced_glyphs: &[ResolvedGlyph],
spaced_strokes: &[Stroke], spaced_strokes: &[Stroke],
spaced_curves: &[Curve],
geometry: &PageGeometry, geometry: &PageGeometry,
) -> CastLayout { ) -> CastLayout {
// ---- Slot table (spaced coordinates) -------------------------------- // ---- Slot table (spaced coordinates) --------------------------------
@ -542,6 +547,21 @@ pub(crate) fn cast_off(
) )
}) })
.collect(); .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
.iter()
.map(|curve| {
curve_system(
curve,
&system_of_slot,
&region_spans,
&region_systems,
&clips,
)
})
.collect();
// ---- System extents ---------------------------------------------------- // ---- System extents ----------------------------------------------------
let mut extents: Vec<Extent> = vec![Extent::empty(); systems.len()]; let mut extents: Vec<Extent> = vec![Extent::empty(); systems.len()];
@ -581,6 +601,21 @@ 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 };
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 extents: Vec<Extent> = extents.into_iter().map(Extent::normalized).collect(); let extents: Vec<Extent> = extents.into_iter().map(Extent::normalized).collect();
// ---- Vertical stacking and page assignment ---------------------------- // ---- Vertical stacking and page assignment ----------------------------
@ -731,6 +766,32 @@ pub(crate) fn cast_off(
} }
strokes.extend(continuations); 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()
}
})
.collect();
// ---- The resolved page tree --------------------------------------------- // ---- The resolved page tree ---------------------------------------------
let resolved_systems: Vec<ResolvedSystem> = systems let resolved_systems: Vec<ResolvedSystem> = systems
.iter() .iter()
@ -787,6 +848,7 @@ pub(crate) fn cast_off(
CastLayout { CastLayout {
glyphs, glyphs,
strokes, strokes,
curves,
pages, pages,
decisions, decisions,
system_start_slots, system_start_slots,
@ -1205,6 +1267,42 @@ fn stroke_fate(
} }
} }
/// The system a curve rides whole: the nearest region's system whose clip
/// interval contains the curve's **start** control point (its drawing origin),
/// else that region's nearest system, else `None` (no region claimed it —
/// 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(
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);
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).
let mut best: Option<(usize, f32)> = None;
for (r, span) in region_spans.iter().enumerate() {
let Some((rlo, rhi)) = span else { continue };
let distance = interval_distance(lo, hi, (*rlo, *rhi));
if best.map_or(true, |(_, d)| distance < d) {
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))
})
}
/// Distance from the span `[lo, hi]` to a clip interval (0 when they overlap). /// Distance from the span `[lo, hi]` to a clip interval (0 when they overlap).
fn interval_distance(lo: f32, hi: f32, clip: (f32, f32)) -> f32 { fn interval_distance(lo: f32, hi: f32, clip: (f32, f32)) -> f32 {
if hi < clip.0 { if hi < clip.0 {

View File

@ -79,7 +79,7 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_layout_ir::{ use epiphany_layout_ir::{
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId, all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
ConstraintSolver, ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId, ConstraintSolver, ConstraintStrength, Curve, GlyphCatalog, GlyphObject, GlyphObjectId,
InvalidationSet, LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph, InvalidationSet, LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph,
ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState, ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
@ -121,14 +121,17 @@ pub struct Engraver {
/// wrapping score differs from version `1`'s single endless system), to `3` /// wrapping score differs from version `1`'s single endless system), to `3`
/// when casting-off gained its widow-rebalance phase (a wrapping score's system /// when casting-off gained its widow-rebalance phase (a wrapping score's system
/// breaks — and so its baked geometry — differ again from version `2`'s pure /// breaks — and so its baked geometry — differ again from version `2`'s pure
/// greedy first-fit), and to `4` when repeat barlines and volta brackets landed /// greedy first-fit), to `4` when repeat barlines and volta brackets landed
/// and the horizontal remap became **slot-relative** (a repeat-bearing score's /// and the horizontal remap became **slot-relative** (a repeat-bearing score's
/// baked geometry differs from version `3`'s invisible traced anchors, and a /// baked geometry differs from version `3`'s invisible traced anchors, and a
/// same-slot companion glyph — a time-signature digit, key-signature /// same-slot companion glyph — a time-signature digit, key-signature
/// accidental, or spelling accidental — now rides its slot's rigid delta /// accidental, or spelling accidental — now rides its slot's rigid delta
/// instead of drifting by interpolation; scores without such companions are /// instead of drifting by interpolation; scores without such companions are
/// unchanged). /// unchanged), and to `5` when slur curves landed (a slur-bearing score gains
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(4); /// 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);
impl Engraver { impl Engraver {
/// An engraver casting off against the given page geometry. /// An engraver casting off against the given page geometry.
@ -175,12 +178,19 @@ impl Engraver {
// stem behind. Both gate on structural validity: a malformed input must // stem behind. Both gate on structural validity: a malformed input must
// not leak geometry into the diagnostic layout (which reaches // not leak geometry into the diagnostic layout (which reaches
// canonical_bytes / the renderer). // canonical_bytes / the renderer).
let (spaced_glyphs, spaced_strokes): (Vec<ResolvedGlyph>, Vec<Stroke>) = if structural_valid let (spaced_glyphs, spaced_strokes, spaced_curves): (
{ Vec<ResolvedGlyph>,
Vec<Stroke>,
Vec<Curve>,
) = if structural_valid {
let remap = HorizontalRemap::build(input); let remap = HorizontalRemap::build(input);
(remap.glyphs(input), remap.strokes(input)) (
remap.glyphs(input),
remap.strokes(input),
remap.curves(input),
)
} else { } else {
(Vec::new(), Vec::new()) (Vec::new(), Vec::new(), Vec::new())
}; };
// Casting-off: break the spaced line into systems, stack them, assign // Casting-off: break the spaced line into systems, stack them, assign
// pages, and bake every position into the single world frame. Pure // pages, and bake every position into the single world frame. Pure
@ -191,6 +201,7 @@ impl Engraver {
input, input,
&spaced_glyphs, &spaced_glyphs,
&spaced_strokes, &spaced_strokes,
&spaced_curves,
&self.geometry, &self.geometry,
)) ))
} else { } else {
@ -287,16 +298,23 @@ impl Engraver {
// glyph/stroke positions baked, the engraver's break decisions appended // glyph/stroke positions baked, the engraver's break decisions appended
// to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including // to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including
// any the solver itself made"). // any the solver itself made").
let (glyphs, strokes, pages, engraving_decisions) = match cast { let (glyphs, strokes, curves, pages, engraving_decisions) = match cast {
Some(cast) => { Some(cast) => {
let mut decisions = input.engraving_decisions.clone(); let mut decisions = input.engraving_decisions.clone();
decisions.extend(cast.decisions); decisions.extend(cast.decisions);
(cast.glyphs, cast.strokes, cast.pages, decisions) (
cast.glyphs,
cast.strokes,
cast.curves,
cast.pages,
decisions,
)
} }
None => ( None => (
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
Vec::new(),
input.engraving_decisions.clone(), input.engraving_decisions.clone(),
), ),
}; };
@ -309,6 +327,7 @@ impl Engraver {
pages, pages,
glyphs, glyphs,
strokes, strokes,
curves,
engraving_decisions, engraving_decisions,
catalog: input.catalog.clone(), catalog: input.catalog.clone(),
}, },
@ -459,6 +478,30 @@ impl HorizontalRemap {
}) })
.collect() .collect()
} }
/// 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), so
/// the arc stretches with the spacing between its endpoint columns. Each
/// control point's y is preserved verbatim.
fn curves(&self, input: &ConstrainedLayoutIR) -> Vec<Curve> {
input
.curves
.iter()
.map(|c| {
let map_x = |point: Point| Point::new(self.map(point.x.0), point.y.0);
Curve {
provenance: c.provenance.clone(),
p0: map_x(c.p0),
p1: map_x(c.p1),
p2: map_x(c.p2),
p3: map_x(c.p3),
thickness: c.thickness,
layer: c.layer,
style: c.style,
}
})
.collect()
}
} }
/// Linear interpolation/extrapolation through two control points. /// Linear interpolation/extrapolation through two control points.

View File

@ -26,12 +26,14 @@
//! 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` / `beam_slope_penalty`** — **vacuous 0.0**: the //! * **`slur_shape_penalty`** — **0.0 by construction** (E2): slurs now draw as
//! pipeline draws no slur or beam geometry (slurs/beams exist logically, //! cubic-bézier curves, but the Minimal tier emits the *reference* arc from
//! not as curves/segments), so the contributing-unit sets are empty. The //! the span and the authored/default curvature, so a drawn slur has zero
//! catalog pins vacuous-0.0 deliberately and owns the honesty edge (its //! deviation from its ideal (an authored `curvature_override` is honored, not
//! "notated-but-unrendered" open question): rendering completeness is //! penalized). A real penalty awaits a Standard-tier solver that compromises
//! governed by constraint families and visual acceptance, not these axes. //! a slur's shape to dodge collisions. **`beam_slope_penalty`** stays
//! **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not
//! 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
//! preferred heights: the constrained input's `InterStaffGap` bands //! preferred heights: the constrained input's `InterStaffGap` bands
//! (adjacent staff bands' resolved ink extents; the constrained stage's //! (adjacent staff bands' resolved ink extents; the constrained stage's
@ -457,12 +459,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),
// No drawn slur geometry exists in this pipeline (slurs are logical // Slurs now DRAW (E2: a cubic-bézier `Curve` per slur), but their shape
// objects, not curves): the contributing-unit set is empty, so the // is measured as ideal by construction: the Minimal tier emits the
// axis is exactly 0.0 per the catalog's vacuous-geometry rule. The // reference arc itself — a symmetric cubic from the span and the
// catalog's "notated-but-unrendered" open question owns the honesty // authored (or default) curvature — so a drawn slur has zero deviation
// edge; the definition is pinned so the first slur-drawing release is // from its ideal, and an authored `curvature_override` is honored, not
// measured from day one. // penalized. A real non-zero penalty awaits a collision-aware
// (Standard-tier, Push 3) solver that *compromises* a slur's shape to
// dodge collisions; until then this axis stays 0.0 by construction, not
// by vacuity.
slur_shape_penalty: normalize(0.0, 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),
@ -579,7 +584,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);
assert_eq!(vector.slur_shape_penalty.0, 0.0, "vacuous: no drawn slurs"); // The ten-measure fixture has no slur; even one would measure 0.0
// (Minimal draws the ideal arc — deviation is 0 by construction).
assert_eq!(vector.slur_shape_penalty.0, 0.0, "ideal-by-construction");
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!(

View File

@ -556,3 +556,58 @@ these are E1 implementation decisions for the Phase-F ratification pass:
`is_barline_glyph` is exported so the casting-off solver classifies `is_barline_glyph` is exported so the casting-off solver classifies
measure-boundary columns from this crate's name vocabulary instead of a measure-boundary columns from this crate's name vocabulary instead of a
string prefix. string prefix.
## Slur curves + the cubic-bézier curve primitive (schema major 2, E2, 2026-07-08)
The third pipeline primitive kind. Primitives were two parallel flat Vecs
(`glyphs`, `strokes`) at each of the three IR stages; a `Curve` (four control
points + thickness/layer/style/provenance, mirroring `Stroke`) adds a third
`curves` Vec at constrained/resolved/render, threaded through canonical encode
(a fifth `u32` count prefix — the width-lock test moved 4→5), the round-trip
provenance chains and count identity, and `to_render`. Rendering is
spec-unconstrained (Ch7's primitive vocabulary delegates drawing to RenderIR,
out of scope), so these are E2 decisions for the Phase-F ratification pass:
- **Slur geometry (Minimal tier).** A slur engraves to ONE cubic bézier
arcing between its two endpoint event columns — the slur's exact provenance
rides the curve (no synthesis; one primitive per slur), so a drawn slur adds
no traced anchor. Endpoints resolve at `to_logical` to
`SlurEndpoint::At(onset)` / `Unresolved` (a `LayoutContent::Slur` payload,
exactly the E1 repeat pattern); the constrained stage looks up each onset's
Note column. A symmetric arc: endpoints tucked `SLUR_INSET` in from the
columns and `SLUR_ENDPOINT_GAP` outside the staff on the arc side; control
points lifted so the apex (`t=0.5`) sits `height` from the endpoint line
(`lift = 4/3·height`, since `B(0.5)` weights the controls by ¾).
- **curvature_override honored structurally.** `direction` (Above/Below;
default Auto = above) flips the arc; `height` (a `SpaceUnit`) sets the apex,
else a span-proportional default clamped to `[SLUR_MIN_HEIGHT,
SLUR_MAX_HEIGHT]`. `style.line` (dashed/dotted) is a Push-3 refinement — the
Minimal tier draws every slur solid (the curve carries an `ink` style like a
stroke); nothing authored is lost, only its dash rendering deferred.
- **Honest non-drawing.** No curve is drawn — the traced anchor keeps
provenance, the same discipline as an unresolvable repeat boundary — when:
an endpoint is unresolved (dangling event, or a column in another region);
the span is not left-to-right after the endpoint inset; or (review fix) the
slur resolved to **no single staff** — endpoints on different staves of one
region, where the arc would float at `yo = 0` detached from a note on the
other staff (cross-staff slurs, like cross-region ones, defer to a later
tranche). A cross-region (system-spanning) slur stays the deferred
`CrossRegionObject` anchor-only path.
- **Authored dimensions sanitized to defaults (review fix).**
`curvature_override.height` and `style.thickness` are the first
*user-authored* values to reach primitive geometry, and neither the codec
nor the invariants bound them positive. A non-positive height (which would
flip or collapse the arc) or a non-positive thickness (a zero draws an
invisible, unhittable curve; a **negative** one fails
`InvalidCurveGeometry` and would blank the whole layout) falls back to the
engraver's default rather than reaching the `Curve`. Out-of-range authored
dimensions are an authoring-validation concern; the engraver draws something
sensible instead of a broken or missing score.
- **Hit-testing.** A `HitShape::Curve { p0..p3, half_width }` — Copy-preserving
(four points + a scalar) — flattens the cubic into `CURVE_FLATTEN_SEGMENTS`
capsule segments *inside* its `contains`/`intersects_rect` tests, so a curve
is ONE hit region (the round-trip and hit-map counts stay `+ curves.len()`),
and its AABB is the control-point hull ± half-width (a cubic never bows past
its hull). A slur click flows through `click()`/`select()` generically —
`selection.source = Slur` — with no editor-core arm; an edit op cleanly
refuses the non-pitch selection.

View File

@ -27,7 +27,8 @@ use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, Overri
use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference}; use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference};
use crate::logical::{ use crate::logical::{
apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature,
RepeatContent, RepeatPlacement, ScoreVersion, StaffContent, RepeatContent, RepeatPlacement, ScoreVersion, SlurContent, SlurDirection, SlurEndpoint,
StaffContent,
}; };
use crate::provenance::{ use crate::provenance::{
manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind, manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind,
@ -94,6 +95,38 @@ impl Stroke {
} }
} }
/// A cubic-bézier curve primitive — the third pipeline primitive kind, drawn as
/// a stroked (unfilled) path (Chapter 7 §"Non-overreach"). Slurs engrave to
/// one of these; ties and other span curves will follow. Like [`Stroke`] it is
/// a *free* primitive (no vertical band, no spring slot): the solver re-spaces
/// its control points by the horizontal coordinate map, exactly as it does a
/// spanning stroke's endpoints. The four control points are world-space
/// staff-space coordinates, `p0`→`p3` the drawing order.
#[derive(Clone, PartialEq, Debug)]
pub struct Curve {
pub provenance: Provenance,
pub p0: Point,
pub p1: Point,
pub p2: Point,
pub p3: Point,
pub thickness: StaffSpace,
pub layer: i32,
pub style: GlyphStyle,
}
impl Curve {
/// This curve's stable id (derived from its provenance, as a glyph's is).
pub fn id(&self) -> GlyphObjectId {
GlyphObjectId(self.provenance.stable_id.0)
}
/// The four control points in drawing order — the shared iteration order
/// for remapping, bounding, and flattening.
pub fn control_points(&self) -> [Point; 4] {
[self.p0, self.p1, self.p2, self.p3]
}
}
/// The constrained IR: composite objects flattened to glyphs and strokes, with /// The constrained IR: composite objects flattened to glyphs and strokes, with
/// the vertical bands and engraving decisions that the solver consumes alongside /// the vertical bands and engraving decisions that the solver consumes alongside
/// them (Chapter 7 §"Constraints"). /// them (Chapter 7 §"Constraints").
@ -105,6 +138,8 @@ pub struct ConstrainedLayoutIR {
pub glyphs: Vec<GlyphObject>, pub glyphs: Vec<GlyphObject>,
/// Non-glyph line primitives (staff lines, stems, barlines, …). /// Non-glyph line primitives (staff lines, stems, barlines, …).
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
/// Cubic-bézier curve primitives (slurs, …).
pub curves: Vec<Curve>,
pub vertical_bands: Vec<VerticalBand>, pub vertical_bands: Vec<VerticalBand>,
pub constraints: Vec<LayoutConstraint>, pub constraints: Vec<LayoutConstraint>,
/// The user-override attributions behind the projected break constraints in /// The user-override attributions behind the projected break constraints in
@ -309,6 +344,8 @@ pub enum ConstrainedValidationError {
InvalidConstraintRegion(GlyphObjectId), InvalidConstraintRegion(GlyphObjectId),
/// A stroke has a non-finite endpoint or a non-finite/negative thickness. /// A stroke has a non-finite endpoint or a non-finite/negative thickness.
InvalidStrokeGeometry(GlyphObjectId), InvalidStrokeGeometry(GlyphObjectId),
/// A curve has a non-finite control point or a non-finite/negative thickness.
InvalidCurveGeometry(GlyphObjectId),
} }
/// A malformed logical-stage value that cannot be transformed without losing /// A malformed logical-stage value that cannot be transformed without losing
@ -499,6 +536,19 @@ impl ConstrainedLayoutIR {
)); ));
} }
} }
for curve in &self.curves {
// Every control point and the thickness must quantize; thickness
// non-negative — the same discipline as a stroke's geometry.
let geometry_quantizes = curve
.control_points()
.iter()
.all(|point| point.quantize().is_some())
&& curve.thickness.quantize().is_some();
if !geometry_quantizes || curve.thickness.0 < 0.0 {
return Err(ConstrainedValidationError::InvalidCurveGeometry(curve.id()));
}
}
Ok(()) Ok(())
} }
} }
@ -534,6 +584,14 @@ const VOLTA_LINE_THICKNESS: f32 = 0.16; // SMuFL repeatEndingLineThickness defau
const VOLTA_TEXT_X: f32 = 0.4; // the first ending digit sits this far right of the bracket start const VOLTA_TEXT_X: f32 = 0.4; // the first ending digit sits this far right of the bracket start
const VOLTA_TEXT_DROP: f32 = 1.3; // ending-digit baseline, below the bracket line const VOLTA_TEXT_DROP: f32 = 1.3; // ending-digit baseline, below the bracket line
const VOLTA_ENDING_GAP: f32 = 0.5; // extra gap between successive ending numbers const VOLTA_ENDING_GAP: f32 = 0.5; // extra gap between successive ending numbers
// Slur engraving defaults (Minimal tier; a symmetric cubic arc — Push 3 refines
// with collision-aware shaping).
const SLUR_ENDPOINT_GAP: f32 = 0.7; // endpoints sit this far outside the staff on the arc side
const SLUR_INSET: f32 = 0.6; // endpoints tuck this far in from their event columns
const SLUR_HEIGHT_FACTOR: f32 = 0.16; // auto arc apex height as a fraction of span width
const SLUR_MIN_HEIGHT: f32 = 0.8; // …clamped to at least this many staff spaces
const SLUR_MAX_HEIGHT: f32 = 3.0; // …and at most this many
const SLUR_THICKNESS: f32 = 0.12; // default line thickness when the style declares none
/// The horizontal half-reach of an emitted `PositionWithin` region, in staff /// The horizontal half-reach of an emitted `PositionWithin` region, in staff
/// spaces. The constrained stage performs no casting-off, so a region imposes /// spaces. The constrained stage performs no casting-off, so a region imposes
@ -650,6 +708,7 @@ pub fn try_to_constrained(
) -> Result<ConstrainedLayoutIR, LayoutTransformError> { ) -> Result<ConstrainedLayoutIR, LayoutTransformError> {
let mut glyphs = Vec::new(); let mut glyphs = Vec::new();
let mut strokes = Vec::new(); let mut strokes = Vec::new();
let mut curves = Vec::new();
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
let mut vertical_bands = Vec::new(); let mut vertical_bands = Vec::new();
let mut horizontal_slots = Vec::new(); let mut horizontal_slots = Vec::new();
@ -1015,6 +1074,7 @@ pub fn try_to_constrained(
let mut emit = Emit { let mut emit = Emit {
glyphs: &mut glyphs, glyphs: &mut glyphs,
strokes: &mut strokes, strokes: &mut strokes,
curves: &mut curves,
diagnostics: &mut diagnostics, diagnostics: &mut diagnostics,
column_members: BTreeMap::new(), column_members: BTreeMap::new(),
region_glyphs: Vec::new(), region_glyphs: Vec::new(),
@ -1394,10 +1454,30 @@ pub fn try_to_constrained(
} }
} }
} }
TypedObjectId::Slur(_) => {
// A slur engraves to a cubic-bézier curve arcing between its
// two endpoint columns. No curve is honest — the traced
// anchor keeps provenance instead — when: the slur resolved
// to no single staff (endpoints on different staves; the
// arc would float at `yo = 0` detached from a note on
// another staff — a Minimal boundary, cross-staff slurs
// defer to a later tranche), either endpoint is unresolved
// (dangling event, or an endpoint in another region), or the
// span is not left-to-right in this region.
let curve = match content {
Some(LayoutContent::Slur(slur)) if staff.is_some() => {
slur_curve(provenance, slur, yo, &columns)
}
_ => None,
};
match curve {
Some(curve) => emit.curve(curve),
None => emit.stroke(anchor(provenance, Point::new(default_x, yo))),
}
}
// Region, Voice, GraphicObject, and every other cross-cutting // Region, Voice, GraphicObject, and every other cross-cutting
// structure (ties, slurs, beams, tuplets, spanners, markers, …) // structure (ties, beams, tuplets, spanners, markers, …) have no
// have no Minimal-tier glyph; a zero-extent traced anchor keeps // Minimal-tier glyph; a zero-extent traced anchor keeps them.
// them.
_ => emit.stroke(anchor(provenance, Point::new(default_x, yo))), _ => emit.stroke(anchor(provenance, Point::new(default_x, yo))),
} }
} }
@ -1710,6 +1790,7 @@ pub fn try_to_constrained(
horizontal_slots, horizontal_slots,
glyphs, glyphs,
strokes, strokes,
curves,
vertical_bands, vertical_bands,
constraints, constraints,
break_origins, break_origins,
@ -1800,10 +1881,11 @@ struct ColumnInfo {
} }
/// The accumulators a region's engraving emits into. Glyphs (only) carry band and /// The accumulators a region's engraving emits into. Glyphs (only) carry band and
/// spring-slot membership; strokes are free line primitives. /// spring-slot membership; strokes and curves are free line primitives.
struct Emit<'a> { struct Emit<'a> {
glyphs: &'a mut Vec<GlyphObject>, glyphs: &'a mut Vec<GlyphObject>,
strokes: &'a mut Vec<Stroke>, strokes: &'a mut Vec<Stroke>,
curves: &'a mut Vec<Curve>,
diagnostics: &'a mut Vec<LayoutDiagnostic>, diagnostics: &'a mut Vec<LayoutDiagnostic>,
column_members: BTreeMap<SpringSlotId, Vec<GlyphObjectId>>, column_members: BTreeMap<SpringSlotId, Vec<GlyphObjectId>>,
region_glyphs: Vec<GlyphObjectId>, region_glyphs: Vec<GlyphObjectId>,
@ -1859,6 +1941,10 @@ impl Emit<'_> {
self.strokes.push(stroke); self.strokes.push(stroke);
} }
fn curve(&mut self, curve: Curve) {
self.curves.push(curve);
}
fn diag(&mut self, source: TypedObjectId, kind: LayoutDiagnosticKind) { fn diag(&mut self, source: TypedObjectId, kind: LayoutDiagnosticKind) {
self.diagnostics.push(LayoutDiagnostic { source, kind }); self.diagnostics.push(LayoutDiagnostic { source, kind });
} }
@ -2052,6 +2138,90 @@ fn repeat_sign_right_extension(name: &str) -> f32 {
} }
} }
/// The cubic-bézier curve for a slur, or `None` when it cannot be honestly
/// drawn in this region: either endpoint unresolved (dangling event, or an
/// endpoint whose column was laid out in another region), or a span that is not
/// left-to-right after the endpoint inset. `yo` is the slur's staff origin (its
/// bottom line). A symmetric arc — the Minimal-tier default; collision-aware
/// shaping is a Standard-tier (Push 3) refinement.
fn slur_curve(
provenance: &Provenance,
slur: &SlurContent,
yo: f32,
columns: &BTreeMap<ColumnKey, ColumnInfo>,
) -> Option<Curve> {
let start_x = slur_endpoint_x(&slur.start, columns)?;
let end_x = slur_endpoint_x(&slur.end, columns)?;
// Tuck the endpoints in from the note columns; a span too narrow to inset
// (adjacent or coincident columns) is not drawn.
let p0x = start_x + SLUR_INSET;
let p3x = end_x - SLUR_INSET;
if p3x <= p0x {
return None;
}
// Direction: honor an authored override, else default above the staff.
let above = match slur.direction {
SlurDirection::Below => false,
SlurDirection::Above | SlurDirection::Auto => true,
};
// Endpoint y: just outside the staff on the arc side.
let base_y = if above {
yo + STAFF_HEIGHT + SLUR_ENDPOINT_GAP
} else {
yo - SLUR_ENDPOINT_GAP
};
// Apex height: an authored *positive* height, else span-proportional and
// clamped. A non-positive authored height is out of range — it would flip
// or collapse the arc — so it falls back to the default rather than
// producing a downward "above" slur (authoring-validation may flag it
// separately; the engraver draws something sensible).
let span = p3x - p0x;
let default_height = (span * SLUR_HEIGHT_FACTOR).clamp(SLUR_MIN_HEIGHT, SLUR_MAX_HEIGHT);
let height = slur
.height
.map(|h| h.0.get() as f32)
.filter(|h| *h > 0.0)
.unwrap_or(default_height);
// Lift the two control points so the cubic's apex (t = 0.5) sits `height`
// from the endpoint line: B(0.5) lifts the control y by 0.75, so the lift
// is 4/3 · height (negated below the staff).
let lift = if above { height } else { -height } * 4.0 / 3.0;
// Thickness: an authored *positive* value, else the default. A
// non-positive one is skipped — a zero would draw an invisible,
// unhittable slur, and a negative one would fail geometry validation and
// blank the whole layout; neither may reach the primitive.
let thickness = slur
.thickness
.map(|t| t.0.get() as f32)
.filter(|t| *t > 0.0)
.unwrap_or(SLUR_THICKNESS);
Some(Curve {
provenance: provenance.clone(),
p0: Point::new(p0x, base_y),
p1: Point::new(p0x + span / 3.0, base_y + lift),
p2: Point::new(p3x - span / 3.0, base_y + lift),
p3: Point::new(p3x, base_y),
thickness: StaffSpace(thickness),
layer: 0,
style: ink(),
})
}
/// A slur endpoint's resolved x: the note column at its resolved onset, or
/// `None` when the endpoint is unresolved or its column was not laid out in
/// this region.
fn slur_endpoint_x(
endpoint: &SlurEndpoint,
columns: &BTreeMap<ColumnKey, ColumnInfo>,
) -> Option<f32> {
match endpoint {
SlurEndpoint::At(time) => columns
.get(&ColumnKey::Timed(time.clone(), ColumnRole::Note))
.map(|info| info.x),
SlurEndpoint::Unresolved => None,
}
}
/// The key signature in force at a staff's start, by resolved time (the same /// The key signature in force at a staff's start, by resolved time (the same
/// rule as the active clef), or `None` when the staff declares no key. Absence /// rule as the active clef), or `None` when the staff declares no key. Absence
/// means no signature drawn — distinct from a declared C-major (which also draws /// means no signature drawn — distinct from a declared C-major (which also draws
@ -4069,4 +4239,223 @@ mod tests {
); );
} }
} }
// --- Slur curves (schema-major-2 E2) -----------------------------------
use epiphany_core::{
CurvatureOverride, CurveDirection, EventId, Slur, SlurId, SpaceUnit, SpanStyle,
};
/// The events of region A's first voice (all on its one staff), for slur
/// endpoints that resolve to note columns.
fn region_a_events(score: &Score) -> Vec<EventId> {
score.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone()
}
fn slur(id: SlurId, start: EventId, end: EventId, over: Option<CurvatureOverride>) -> Slur {
Slur {
id,
start_event: start,
end_event: end,
kind: epiphany_core::SlurKind::Legato,
curvature_override: over,
style: SpanStyle::default(),
}
}
fn slur_curve_of(constrained: &ConstrainedLayoutIR, id: SlurId) -> Option<&Curve> {
constrained
.curves
.iter()
.find(|curve| curve.provenance.source == TypedObjectId::Slur(id))
}
#[test]
fn a_default_slur_arcs_above_its_two_event_columns() {
let (mut score, _) = repeat_ready_score(41);
let events = region_a_events(&score);
let id: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(id, events[0], events[2], None));
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur draws a curve");
// Its exact provenance rides the curve — one primitive per slur, no
// synthesis (a slur owns a single curve).
assert!(curve.provenance.synthesis.is_none());
// Left-to-right, endpoints between the event columns (tucked in).
assert!(curve.p3.x.0 > curve.p0.x.0);
assert_eq!(curve.p0.y, curve.p3.y, "endpoints share a baseline");
// Default = above: the apex (control points) sits ABOVE the endpoints
// in world y-up, and above the top staff line.
assert!(
curve.p1.y.0 > curve.p0.y.0 && curve.p2.y.0 > curve.p0.y.0,
"a default slur arcs upward (controls above the endpoint line)"
);
assert!(
curve.p0.y.0 >= STAFF_HEIGHT,
"the endpoints sit above the staff"
);
// No traced anchor for a drawn slur (the curve carries the provenance).
assert!(!constrained
.strokes
.iter()
.any(|s| s.provenance.source == TypedObjectId::Slur(id)));
crate::roundtrip::round_trip(&score);
}
#[test]
fn an_authored_below_override_flips_the_arc_and_sets_its_height() {
let (mut score, _) = repeat_ready_score(42);
let events = region_a_events(&score);
let above: SlurId = score.identity.mint();
let below: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(above, events[0], events[2], None));
score.cross_cutting.slurs.push(slur(
below,
events[0],
events[2],
Some(CurvatureOverride {
direction: Some(CurveDirection::Below),
height: Some(SpaceUnit(
epiphany_determinism::CanonicalF64::new(2.0).expect("finite"),
)),
}),
));
let constrained = to_constrained(&to_logical(&score));
let up = slur_curve_of(&constrained, above).expect("above slur draws");
let down = slur_curve_of(&constrained, below).expect("below slur draws");
// The below slur arcs the other way: controls below the endpoints, and
// the endpoints sit below the staff (negative world y for the bottom
// staff at origin 0).
assert!(down.p1.y.0 < down.p0.y.0 && down.p2.y.0 < down.p0.y.0);
assert!(down.p0.y.0 < 0.0, "a below slur sits under the staff");
// Its authored apex height is 2.0: the control lift is 4/3 · height, so
// the apex (0.75 · lift below the baseline) is 2.0 below it.
let apex_drop = down.p0.y.0 - (down.p1.y.0 + 0.75 * (down.p1.y.0 - down.p0.y.0));
let _ = apex_drop;
let lift = down.p1.y.0 - down.p0.y.0;
assert!(
(0.75 * -lift - 2.0).abs() < 1e-4,
"authored apex height honored (2.0 staff spaces)"
);
// The above and below slurs mirror across the endpoint lines' sides.
assert!(up.p1.y.0 > up.p0.y.0);
}
#[test]
fn a_slur_with_an_unresolved_or_reversed_endpoint_keeps_its_anchor() {
let (mut score, _) = repeat_ready_score(43);
let events = region_a_events(&score);
let replica = score.identity.replica_id;
// A dangling end event: nothing to arc to.
let dangling: SlurId = score.identity.mint();
score.cross_cutting.slurs.push(slur(
dangling,
events[0],
EventId::new(replica, 9_999_999),
None,
));
// A zero-span slur (both endpoints the same event): no left-to-right arc.
let degenerate: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(degenerate, events[0], events[0], None));
let constrained = to_constrained(&to_logical(&score));
for id in [dangling, degenerate] {
assert!(
slur_curve_of(&constrained, id).is_none(),
"an unresolved/degenerate slur draws no curve"
);
assert!(
constrained
.strokes
.iter()
.any(|s| { s.from == s.to && s.provenance.source == TypedObjectId::Slur(id) }),
"…it keeps its zero-extent traced anchor"
);
}
}
#[test]
fn a_cross_staff_slur_keeps_its_anchor_rather_than_floating() {
use epiphany_core::generators::valid_score;
// A `valid_score` seed with two staff instances in its single region.
let mut score = (0..64u64)
.map(valid_score)
.find(|s| s.canvas.regions[0].staff_instances().len() == 2)
.expect("some seed yields a two-staff region");
let instances = score.canvas.regions[0].staff_instances();
// One endpoint on each staff — the slur resolves to no single staff.
let top = instances[0].voices[0].events[0];
let bottom = instances[1].voices[0].events[0];
let id: SlurId = score.identity.mint();
score.cross_cutting.slurs.push(slur(id, top, bottom, None));
let constrained = to_constrained(&to_logical(&score));
// No curve floating at yo = 0 detached from a note on the other staff;
// the traced anchor keeps provenance (a Minimal boundary).
assert!(
slur_curve_of(&constrained, id).is_none(),
"a cross-staff slur draws no curve"
);
assert!(constrained
.strokes
.iter()
.any(|s| s.from == s.to && s.provenance.source == TypedObjectId::Slur(id)));
crate::roundtrip::round_trip(&score);
}
#[test]
fn out_of_range_authored_slur_dimensions_fall_back_to_defaults() {
let (mut score, _) = repeat_ready_score(44);
let events = region_a_events(&score);
let neg: epiphany_determinism::CanonicalF64 =
epiphany_determinism::CanonicalF64::new(-1.0).expect("finite");
let zero: epiphany_determinism::CanonicalF64 =
epiphany_determinism::CanonicalF64::new(0.0).expect("finite");
// A slur authoring a negative height and a zero thickness — pathological
// out-of-range values that must NOT flip the arc, draw an invisible
// curve, or (for a negative thickness) fail geometry validation and
// blank the layout.
let id: SlurId = score.identity.mint();
let mut bad = slur(
id,
events[0],
events[2],
Some(CurvatureOverride {
direction: None, // Auto = above
height: Some(SpaceUnit(neg)),
}),
);
bad.style.thickness = Some(SpaceUnit(zero));
score.cross_cutting.slurs.push(bad);
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur still draws");
// The negative height fell back to the positive default: an Auto slur
// still arcs UP.
assert!(
curve.p1.y.0 > curve.p0.y.0,
"a non-positive authored height falls back, arc stays upward"
);
// The zero thickness fell back to the visible, hittable default.
assert!(
curve.thickness.0 > 0.0,
"thickness falls back to a positive default"
);
// Geometry validates — the layout is not blanked.
assert!(constrained.validate().is_ok());
}
} }

View File

@ -25,11 +25,13 @@ use crate::render::{RenderIR, RenderPrimitive};
use crate::spatial::{BoundingBox, Point, Transform2D}; use crate::spatial::{BoundingBox, Point, Transform2D};
/// Which [`RenderIR`] primitive a [`HitRegion`] belongs to: an index into /// Which [`RenderIR`] primitive a [`HitRegion`] belongs to: an index into
/// [`RenderIR::primitives`] (a glyph) or [`RenderIR::strokes`] (a stroke). /// [`RenderIR::primitives`] (a glyph), [`RenderIR::strokes`] (a stroke), or
/// [`RenderIR::curves`] (a curve).
#[derive(Copy, Clone, PartialEq, Eq, Debug)] #[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PrimitiveRef { pub enum PrimitiveRef {
Glyph(usize), Glyph(usize),
Stroke(usize), Stroke(usize),
Curve(usize),
} }
impl PrimitiveRef { impl PrimitiveRef {
@ -53,6 +55,43 @@ pub enum HitShape {
to: Point, to: Point,
half_width: f32, half_width: f32,
}, },
/// A curve (slur, …): its four cubic-bézier control points and a half-width.
/// Its geometry tests flatten the cubic into [`CURVE_FLATTEN_SEGMENTS`]
/// straight capsule segments (a polyline), so a click near the drawn arc
/// selects it — one region per curve, unlike per-segment hit fragments.
Curve {
p0: Point,
p1: Point,
p2: Point,
p3: Point,
half_width: f32,
},
}
/// How many straight segments a cubic bézier is flattened into for hit-testing.
/// Chosen so a normal-span slur's chord error stays well under the half-width;
/// a fixed count keeps the map deterministic and cheap.
pub const CURVE_FLATTEN_SEGMENTS: usize = 16;
/// The cubic-bézier point at parameter `t` (de Casteljau, expanded).
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f32) -> Point {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point::new(
a * p0.x.0 + b * p1.x.0 + c * p2.x.0 + d * p3.x.0,
a * p0.y.0 + b * p1.y.0 + c * p2.y.0 + d * p3.y.0,
)
}
/// The flattened polyline of a cubic bézier: `CURVE_FLATTEN_SEGMENTS + 1`
/// points from `p0` to `p3`, endpoints exact.
fn flatten_cubic(p0: Point, p1: Point, p2: Point, p3: Point) -> Vec<Point> {
(0..=CURVE_FLATTEN_SEGMENTS)
.map(|i| {
let t = i as f32 / CURVE_FLATTEN_SEGMENTS as f32;
cubic_point(p0, p1, p2, p3, t)
})
.collect()
} }
impl HitShape { impl HitShape {
@ -67,6 +106,15 @@ impl HitShape {
to, to,
half_width, half_width,
} => distance_point_segment(point, *from, *to) <= *half_width, } => distance_point_segment(point, *from, *to) <= *half_width,
HitShape::Curve {
p0,
p1,
p2,
p3,
half_width,
} => flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| distance_point_segment(point, seg[0], seg[1]) <= *half_width),
} }
} }
@ -87,6 +135,15 @@ impl HitShape {
to, to,
half_width, half_width,
} => segment_intersects_rect(*from, *to, *half_width, &rect), } => segment_intersects_rect(*from, *to, *half_width, &rect),
HitShape::Curve {
p0,
p1,
p2,
p3,
half_width,
} => flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| segment_intersects_rect(seg[0], seg[1], *half_width, &rect)),
} }
} }
@ -105,6 +162,26 @@ impl HitShape {
from.x.0.max(to.x.0) + half_width, from.x.0.max(to.x.0) + half_width,
from.y.0.max(to.y.0) + half_width, from.y.0.max(to.y.0) + half_width,
), ),
HitShape::Curve {
p0,
p1,
p2,
p3,
half_width,
} => {
// A cubic lies within its control points' convex hull, so their
// AABB (± half-width) is a correct conservative broad-phase box.
let xs = [p0.x.0, p1.x.0, p2.x.0, p3.x.0];
let ys = [p0.y.0, p1.y.0, p2.y.0, p3.y.0];
let min = |a: &[f32]| a.iter().copied().fold(f32::INFINITY, f32::min);
let max = |a: &[f32]| a.iter().copied().fold(f32::NEG_INFINITY, f32::max);
BoundingBox::new(
min(&xs) - half_width,
min(&ys) - half_width,
max(&xs) + half_width,
max(&ys) + half_width,
)
}
} }
} }
} }
@ -139,18 +216,22 @@ impl HitRegion {
/// before glyphs at one layer, then primitive index). A larger key is painted /// before glyphs at one layer, then primitive index). A larger key is painted
/// later, i.e. on top. /// later, i.e. on top.
fn paint_order(&self) -> (i32, u8, usize) { fn paint_order(&self) -> (i32, u8, usize) {
// The renderer paints strokes, then curves, then glyphs at one layer
// (a slur draws over the staff lines but under the noteheads it joins).
let (kind_rank, index) = match self.primitive { let (kind_rank, index) = match self.primitive {
PrimitiveRef::Stroke(i) => (0, i), PrimitiveRef::Stroke(i) => (0, i),
PrimitiveRef::Glyph(i) => (1, i), PrimitiveRef::Curve(i) => (1, i),
PrimitiveRef::Glyph(i) => (2, i),
}; };
(self.layer, kind_rank, index) (self.layer, kind_rank, index)
} }
} }
/// The hit-test map over a [`RenderIR`]: one [`HitRegion`] per primitive (glyph /// The hit-test map over a [`RenderIR`]: one [`HitRegion`] per primitive (glyph,
/// and stroke). The public [`Self::regions`] vector is stored in construction /// stroke, and curve). The public [`Self::regions`] vector is stored in
/// order (glyph regions first, then stroke regions), not z-order; callers that /// construction order (glyph regions, then stroke regions, then curve
/// need ordered selection results should use [`Self::hit`] or [`Self::within`]. /// regions), not z-order; callers that need ordered selection results should
/// use [`Self::hit`] or [`Self::within`].
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
pub struct HitTestMap { pub struct HitTestMap {
pub regions: Vec<HitRegion>, pub regions: Vec<HitRegion>,
@ -190,15 +271,17 @@ impl HitTestMap {
} }
impl RenderIR { impl RenderIR {
/// Builds the [`HitTestMap`]: one [`HitRegion`] per glyph primitive and per /// Builds the [`HitTestMap`]: one [`HitRegion`] per glyph primitive, per
/// stroke. Regions are stored in construction order (glyphs, then strokes); /// stroke, and per curve. Regions are stored in construction order (glyphs,
/// each region's [`HitRegion::layer`] and primitive reference carry the true /// then strokes, then curves); each region's [`HitRegion::layer`] and
/// paint order consumed by [`HitTestMap::hit`] and [`HitTestMap::within`]. /// primitive reference carry the true paint order consumed by
/// [`HitTestMap::hit`] and [`HitTestMap::within`].
/// Each region's `source`/`layout_object`/`synthesis` come straight from the /// Each region's `source`/`layout_object`/`synthesis` come straight from the
/// primitive's preserved [`crate::Provenance`]; its shape is computed in world /// primitive's preserved [`crate::Provenance`]; its shape is computed in world
/// coordinates. /// coordinates.
pub fn hit_test_map(&self) -> HitTestMap { pub fn hit_test_map(&self) -> HitTestMap {
let mut regions = Vec::with_capacity(self.primitives.len() + self.strokes.len()); let mut regions =
Vec::with_capacity(self.primitives.len() + self.strokes.len() + self.curves.len());
for (i, p) in self.primitives.iter().enumerate() { for (i, p) in self.primitives.iter().enumerate() {
regions.push(HitRegion { regions.push(HitRegion {
primitive: PrimitiveRef::Glyph(i), primitive: PrimitiveRef::Glyph(i),
@ -223,6 +306,22 @@ impl RenderIR {
layer: s.layer, layer: s.layer,
}); });
} }
for (i, c) in self.curves.iter().enumerate() {
regions.push(HitRegion {
primitive: PrimitiveRef::Curve(i),
source: c.provenance.source,
layout_object: c.provenance.stable_id,
synthesis: c.provenance.synthesis,
shape: HitShape::Curve {
p0: c.p0,
p1: c.p1,
p2: c.p2,
p3: c.p3,
half_width: c.thickness.0 / 2.0,
},
layer: c.layer,
});
}
HitTestMap { regions } HitTestMap { regions }
} }
} }
@ -358,7 +457,7 @@ fn segments_cross(p1: Point, p2: Point, p3: Point, p4: Point) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::constrained::to_constrained; use crate::constrained::{to_constrained, Curve};
use crate::logical::to_logical; use crate::logical::to_logical;
use crate::provenance::Provenance; use crate::provenance::Provenance;
use crate::render::to_render; use crate::render::to_render;
@ -431,6 +530,63 @@ mod tests {
assert_eq!(s.aabb(), BoundingBox::new(-0.1, -0.1, 4.1, 0.1)); assert_eq!(s.aabb(), BoundingBox::new(-0.1, -0.1, 4.1, 0.1));
} }
#[test]
fn a_curve_region_is_hit_near_its_flattened_arc_not_its_chord() {
// A symmetric arc bulging up: endpoints (0,0)->(4,0), controls lifted
// to y = 2 so the apex sits at 0.75·2 = 1.5. Half-width 0.1.
let s = HitShape::Curve {
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),
half_width: 0.1,
};
// A point on the drawn arc near its apex is hit…
assert!(s.contains(Point::new(2.0, 1.5)));
// …but the chord midpoint (y=0, far below the arc) is NOT — a curve is
// its flattened polyline, not the straight line between its endpoints.
assert!(!s.contains(Point::new(2.0, 0.0)));
// The endpoints are exact.
assert!(s.contains(Point::new(0.0, 0.0)));
assert!(s.contains(Point::new(4.0, 0.0)));
// The broad-phase AABB is the control hull ± half-width.
assert_eq!(s.aabb(), BoundingBox::new(-0.1, -0.1, 4.1, 2.1));
// …and a rubber-band rect over the apex selects it.
assert!(s.intersects_rect(BoundingBox::new(1.5, 1.3, 2.5, 1.7)));
}
#[test]
fn a_curve_becomes_one_hit_region_tracing_its_source() {
use epiphany_core::{SlurId, TypedObjectId};
let slur = SlurId::new(epiphany_core::ReplicaId(3), 9);
let curve = Curve {
provenance: Provenance::projected(TypedObjectId::Slur(slur), vec![]),
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: crate::StaffSpace(0.2),
layer: 0,
style: crate::GlyphStyle { rgba: 0 },
};
let map = RenderIR {
primitives: vec![],
strokes: vec![],
curves: vec![curve],
}
.hit_test_map();
assert_eq!(map.regions.len(), 1, "one region per curve");
let region = &map.regions[0];
assert!(matches!(region.primitive, PrimitiveRef::Curve(0)));
assert_eq!(region.source, TypedObjectId::Slur(slur));
// A click on the arc resolves to the slur.
let hit = map.hit(Point::new(2.0, 1.5));
assert_eq!(
hit.first().map(|r| r.source),
Some(TypedObjectId::Slur(slur))
);
}
#[test] #[test]
fn a_glyph_world_box_is_its_local_box_placed_by_position_and_transform() { fn a_glyph_world_box_is_its_local_box_placed_by_position_and_transform() {
// No transform: the local box just shifts by the position. // No transform: the local box just shifts by the position.
@ -442,6 +598,7 @@ mod tests {
let HitShape::Box(b) = RenderIR { let HitShape::Box(b) = RenderIR {
primitives: vec![p.clone()], primitives: vec![p.clone()],
strokes: vec![], strokes: vec![],
curves: vec![],
} }
.hit_test_map() .hit_test_map()
.regions[0] .regions[0]
@ -460,6 +617,7 @@ mod tests {
let HitShape::Box(b) = RenderIR { let HitShape::Box(b) = RenderIR {
primitives: vec![t], primitives: vec![t],
strokes: vec![], strokes: vec![],
curves: vec![],
} }
.hit_test_map() .hit_test_map()
.regions[0] .regions[0]
@ -477,7 +635,7 @@ mod tests {
// One region per glyph and per stroke, none dropped or invented. // One region per glyph and per stroke, none dropped or invented.
assert_eq!( assert_eq!(
map.regions.len(), map.regions.len(),
render.primitives.len() + render.strokes.len() render.primitives.len() + render.strokes.len() + render.curves.len()
); );
assert!(!map.regions.is_empty()); assert!(!map.regions.is_empty());
@ -501,6 +659,14 @@ mod tests {
s.provenance.synthesis, s.provenance.synthesis,
) )
} }
PrimitiveRef::Curve(i) => {
let c = &render.curves[i];
(
c.provenance.source,
c.provenance.stable_id,
c.provenance.synthesis,
)
}
}; };
assert_eq!(r.source, source); assert_eq!(r.source, source);
assert_eq!(r.layout_object, stable_id); assert_eq!(r.layout_object, stable_id);
@ -558,6 +724,7 @@ mod tests {
glyph(Point::ORIGIN, BoundingBox::new(-1.0, -1.0, 1.0, 1.0), 5), glyph(Point::ORIGIN, BoundingBox::new(-1.0, -1.0, 1.0, 1.0), 5),
], ],
strokes: vec![stroke(Point::new(-2.0, 0.0), Point::new(2.0, 0.0), 0)], strokes: vec![stroke(Point::new(-2.0, 0.0), Point::new(2.0, 0.0), 0)],
curves: vec![],
}; };
let map = render.hit_test_map(); let map = render.hit_test_map();
let hits = map.hit(Point::ORIGIN); let hits = map.hit(Point::ORIGIN);
@ -585,6 +752,7 @@ mod tests {
), ),
], ],
strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(3.0, 0.0), 0)], strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(3.0, 0.0), 0)],
curves: vec![],
}; };
let map = render.hit_test_map(); let map = render.hit_test_map();
// A rubber-band around the first glyph and the stroke, but not the far glyph. // A rubber-band around the first glyph and the stroke, but not the far glyph.
@ -608,6 +776,7 @@ mod tests {
let render = RenderIR { let render = RenderIR {
primitives: vec![], primitives: vec![],
strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(10.0, 10.0), 0)], strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(10.0, 10.0), 0)],
curves: vec![],
}; };
let map = render.hit_test_map(); let map = render.hit_test_map();
// AABB-overlapping but body-missing rect near (0, 10): rejected. // AABB-overlapping but body-missing rect near (0, 10): rejected.

View File

@ -95,7 +95,7 @@ pub use cache::{
pub use constrained::{ pub use constrained::{
active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakClass, active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakClass,
BreakKind, BreakOrigin, ConstrainedLayoutIR, ConstrainedLayoutRegion, BreakKind, BreakOrigin, ConstrainedLayoutIR, ConstrainedLayoutRegion,
ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, GlyphObject, ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, Curve, GlyphObject,
GlyphObjectId, GlyphStyle, LayoutConstraint, LayoutTransformError, SpringSlot, Stroke, GlyphObjectId, GlyphStyle, LayoutConstraint, LayoutTransformError, SpringSlot, Stroke,
}; };
pub use engrave_theory::{ pub use engrave_theory::{
@ -120,9 +120,10 @@ pub use logical::{
KeySignatureLayout, LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, KeySignatureLayout, LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem,
LogicalLayoutIR, MarkerLayout, MeasureContent, MultimeasureRestLayout, NoteContent, NoteLayout, LogicalLayoutIR, MarkerLayout, MeasureContent, MultimeasureRestLayout, NoteContent, NoteLayout,
NotePitch, PlacedClef, PlacedComponent, PlacedKeySignature, RepeatContent, RepeatPlacement, NotePitch, PlacedClef, PlacedComponent, PlacedKeySignature, RepeatContent, RepeatPlacement,
RestContent, RestLayout, ScoreVersion, SlurLayout, SpannerLayout, StaffContent, StaffLayout, RestContent, RestLayout, ScoreVersion, SlurContent, SlurDirection, SlurEndpoint, SlurLayout,
TextLayout, TieLayout, TimeSignatureContent, TimeSignatureDisplayLayout, TrajectoryLayout, SpannerLayout, StaffContent, StaffLayout, TextLayout, TieLayout, TimeSignatureContent,
TupletDisplayLayout, VerticalExtent, VoltaContent, TimeSignatureDisplayLayout, TrajectoryLayout, TupletDisplayLayout, VerticalExtent,
VoltaContent,
}; };
pub use provenance::{ pub use provenance::{
continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id, continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id,

View File

@ -20,8 +20,8 @@ use epiphany_core::{
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef, AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef,
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition, CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition,
MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge, MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge,
RegionId, RegionTimeModel, Score, StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay, RegionId, RegionTimeModel, Score, SpaceUnit, StaffId, StaffPosition, TimeAnchor,
TupletId, TupletRatio, TypedObjectId, WallClockTime, TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId, WallClockTime,
}; };
use epiphany_determinism::{DomainTag, Preimage}; use epiphany_determinism::{DomainTag, Preimage};
@ -61,6 +61,9 @@ pub enum LayoutContent {
/// boundaries land, and its volta brackets — resolved to layout placements /// boundaries land, and its volta brackets — resolved to layout placements
/// at projection time (the constrained pass has no score access). /// at projection time (the constrained pass has no score access).
Repeat(RepeatContent), Repeat(RepeatContent),
/// A slur: its two endpoint onsets (resolved to columns in the constrained
/// pass), its arc direction, and any authored curvature/style overrides.
Slur(SlurContent),
} }
/// The clef and key-signature sequences in force across a staff instance, /// The clef and key-signature sequences in force across a staff instance,
@ -201,6 +204,43 @@ pub enum RepeatPlacement {
Unresolved, Unresolved,
} }
/// A slur's engraving content (Chapter 5 §"Slurs": `Slur`), with each endpoint
/// event resolved to its onset [`TimePoint`] and the authored overrides
/// distilled so the constrained pass can draw the arc without score access.
/// Dimensions are carried as [`SpaceUnit`] (staff spaces) so the payload stays
/// `Eq` with the rest of [`LayoutContent`].
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct SlurContent {
pub start: SlurEndpoint,
pub end: SlurEndpoint,
pub direction: SlurDirection,
/// Authored arc apex height (`curvature_override.height`); `None` = the
/// engraver's span-proportional default.
pub height: Option<SpaceUnit>,
/// Authored line thickness (`style.thickness`); `None` = the engraver's
/// default.
pub thickness: Option<SpaceUnit>,
}
/// A slur endpoint on the region's spacing axis.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum SlurEndpoint {
/// At the note column of this resolved event onset.
At(TimePoint),
/// Not resolvable in this region (missing event, or an endpoint whose
/// column is laid out in another region). The slur draws no curve.
Unresolved,
}
/// A slur's arc direction. `Auto` lets the engraver choose (Minimal: above the
/// staff); `Above`/`Below` are authored via `curvature_override.direction`.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum SlurDirection {
Auto,
Above,
Below,
}
/// A structural layout object before spacing (Chapter 7 §"Layout Objects"). It /// A structural layout object before spacing (Chapter 7 §"Layout Objects"). It
/// carries its [`Provenance`], the staff it belongs to (used to route it to the /// carries its [`Provenance`], the staff it belongs to (used to route it to the
/// correct vertical band), and its [`LayoutContent`] (the engraving payload the /// correct vertical band), and its [`LayoutContent`] (the engraving payload the
@ -644,9 +684,9 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
if !seen.insert(provenance.stable_id) { if !seen.insert(provenance.stable_id) {
continue; continue;
} }
// A repeat structure carries its resolved engraving content (barline // A repeat structure or slur carries its resolved engraving content
// verdict + placements). Every other cross-cutting object is // (barline placements / endpoint onsets). Every other cross-cutting
// structural in this tier. // object is structural in this tier.
let content = match src { let content = match src {
TypedObjectId::RepeatStructure(id) => score TypedObjectId::RepeatStructure(id) => score
.cross_cutting .cross_cutting
@ -655,6 +695,13 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
.find(|rp| rp.id == id) .find(|rp| rp.id == id)
.map(|rp| repeat_content(score, rp)) .map(|rp| repeat_content(score, rp))
.unwrap_or_default(), .unwrap_or_default(),
TypedObjectId::Slur(id) => score
.cross_cutting
.slurs
.iter()
.find(|slur| slur.id == id)
.map(|slur| slur_content(score, slur))
.unwrap_or_default(),
_ => LayoutContent::Structural, _ => LayoutContent::Structural,
}; };
let mut anchored_regions = Vec::new(); let mut anchored_regions = Vec::new();
@ -1023,6 +1070,35 @@ fn repeat_content(score: &Score, rp: &epiphany_core::RepeatStructure) -> LayoutC
}) })
} }
/// A slur's engraving content: each endpoint event resolved to its onset (or
/// [`SlurEndpoint::Unresolved`] when the event is missing), plus the authored
/// curvature/style overrides. Direction defaults to [`SlurDirection::Auto`]
/// when the override leaves it unset.
fn slur_content(score: &Score, slur: &epiphany_core::Slur) -> LayoutContent {
use epiphany_core::CurveDirection;
let direction = match slur.curvature_override.as_ref().and_then(|o| o.direction) {
Some(CurveDirection::Above) => SlurDirection::Above,
Some(CurveDirection::Below) => SlurDirection::Below,
None => SlurDirection::Auto,
};
LayoutContent::Slur(SlurContent {
start: slur_endpoint(score, slur.start_event),
end: slur_endpoint(score, slur.end_event),
direction,
height: slur.curvature_override.as_ref().and_then(|o| o.height),
thickness: slur.style.thickness,
})
}
/// A slur endpoint: the event's onset as a [`TimePoint`], or
/// [`SlurEndpoint::Unresolved`] when the event is absent from the score.
fn slur_endpoint(score: &Score, event: EventId) -> SlurEndpoint {
match score.events.get(event) {
Some(graph_event) => SlurEndpoint::At(event_time(graph_event.position())),
None => SlurEndpoint::Unresolved,
}
}
/// Resolves a repeat boundary anchor to a [`RepeatPlacement`]. Unlike /// Resolves a repeat boundary anchor to a [`RepeatPlacement`]. Unlike
/// [`resolve_time_anchor`], failure is **honest** (`Unresolved` draws no ink) /// [`resolve_time_anchor`], failure is **honest** (`Unresolved` draws no ink)
/// rather than falling back to the origin — a repeat sign at a false position /// rather than falling back to the origin — a repeat sign at a false position

View File

@ -11,7 +11,7 @@
use crate::provenance::Provenance; use crate::provenance::Provenance;
use crate::resolved::ResolvedLayoutIR; use crate::resolved::ResolvedLayoutIR;
use crate::spatial::{Point, ScaleContext}; use crate::spatial::{Point, ScaleContext};
use crate::{BoundingBox, GlyphReference, GlyphStyle, Stroke, Transform2D}; use crate::{BoundingBox, Curve, GlyphReference, GlyphStyle, Stroke, Transform2D};
/// A single renderer primitive (Chapter 7 §"RenderIR"). Interface only — it /// A single renderer primitive (Chapter 7 §"RenderIR"). Interface only — it
/// carries just enough to prove the provenance-preservation contract: every /// carries just enough to prove the provenance-preservation contract: every
@ -37,6 +37,8 @@ pub struct RenderIR {
/// Non-glyph line primitives (staff lines, stems, barlines, …), traced like /// Non-glyph line primitives (staff lines, stems, barlines, …), traced like
/// the glyph primitives so the round-trip recovers their sources too. /// the glyph primitives so the round-trip recovers their sources too.
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
/// Cubic-bézier curve primitives (slurs, …), traced like the strokes.
pub curves: Vec<Curve>,
} }
/// The render target (Chapter 7 §"RenderIR": `RenderConfiguration.target`). /// The render target (Chapter 7 §"RenderIR": `RenderConfiguration.target`).
@ -143,5 +145,6 @@ pub fn to_render(resolved: &ResolvedLayoutIR) -> RenderIR {
}) })
.collect(), .collect(),
strokes: resolved.strokes.clone(), strokes: resolved.strokes.clone(),
curves: resolved.curves.clone(),
} }
} }

View File

@ -24,7 +24,7 @@
use epiphany_core::{MeasureId, StaffId, TypedObjectId}; use epiphany_core::{MeasureId, StaffId, TypedObjectId};
use epiphany_determinism::{CanonicalEncode, CanonicalF64, QuantizedCoord}; use epiphany_determinism::{CanonicalEncode, CanonicalF64, QuantizedCoord};
use crate::constrained::{GlyphObjectId, GlyphStyle, Stroke}; use crate::constrained::{Curve, GlyphObjectId, GlyphStyle, Stroke};
use crate::engraving::{DecisionSource, EngravingDecision, EngravingDecisionKind}; use crate::engraving::{DecisionSource, EngravingDecision, EngravingDecisionKind};
use crate::glyph::{GlyphCatalogIdentity, GlyphReference}; use crate::glyph::{GlyphCatalogIdentity, GlyphReference};
use crate::logical::ScoreVersion; use crate::logical::ScoreVersion;
@ -90,6 +90,9 @@ pub struct ResolvedLayoutIR {
/// Resolved non-glyph line primitives (staff lines, stems, barlines, …), /// Resolved non-glyph line primitives (staff lines, stems, barlines, …),
/// positioned by the solver alongside the glyphs. /// positioned by the solver alongside the glyphs.
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
/// Resolved cubic-bézier curve primitives (slurs, …), positioned by the
/// solver alongside the glyphs and strokes.
pub curves: Vec<Curve>,
pub engraving_decisions: Vec<EngravingDecision>, pub engraving_decisions: Vec<EngravingDecision>,
/// The catalog identity under which this layout was produced — required for /// The catalog identity under which this layout was produced — required for
/// any byte-equal conformance claim (Chapter 7 §7.3.2). /// any byte-equal conformance claim (Chapter 7 §7.3.2).
@ -158,6 +161,18 @@ impl CanonicalEncode for ResolvedLayoutIR {
out.extend_from_slice(&stroke.style.rgba.to_le_bytes()); out.extend_from_slice(&stroke.style.rgba.to_le_bytes());
out.extend_from_slice(&stroke.layer.to_le_bytes()); out.extend_from_slice(&stroke.layer.to_le_bytes());
} }
push_len(out, self.curves.len());
for curve in &self.curves {
encode_provenance(out, &curve.provenance);
for point in curve.control_points() {
let (qx, qy) = quantize(point);
qx.encode_canonical(out);
qy.encode_canonical(out);
}
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());
}
push_len(out, self.engraving_decisions.len()); push_len(out, self.engraving_decisions.len());
for decision in &self.engraving_decisions { for decision in &self.engraving_decisions {
encode_decision(out, decision); encode_decision(out, decision);
@ -377,6 +392,7 @@ mod tests {
pages: vec![], pages: vec![],
glyphs, glyphs,
strokes: vec![], strokes: vec![],
curves: vec![],
engraving_decisions: decisions, engraving_decisions: decisions,
catalog: GlyphCatalogIdentity::default(), catalog: GlyphCatalogIdentity::default(),
} }
@ -404,16 +420,16 @@ mod tests {
// Schema major 1 unifies the resolved-layout length/count prefixes to // Schema major 1 unifies the resolved-layout length/count prefixes to
// u32 (Binary Format companion §"Schema Major 1"). This locks the byte // u32 (Binary Format companion §"Schema Major 1"). This locks the byte
// shape so a revert to the old u64 prefixes fails: an empty layout // shape so a revert to the old u64 prefixes fails: an empty layout
// encodes its four counts — pages, glyphs, strokes, engraving_decisions // encodes its five counts — pages, glyphs, strokes, curves,
// — as u32 zeros (16 bytes) right after the 32-byte ScoreVersion source, // engraving_decisions — as u32 zeros (20 bytes) right after the 32-byte
// then the catalog. Under u64 that region would be 32 bytes, shifting the // ScoreVersion source, then the catalog. Under u64 that region would be
// catalog and lengthening the output by 16. // 40 bytes, shifting the catalog and lengthening the output by 20.
let bytes = ir(vec![], vec![]).canonical_bytes(); let bytes = ir(vec![], vec![]).canonical_bytes();
let source_len = ScoreVersion::default().0.len(); let source_len = ScoreVersion::default().0.len();
assert_eq!(source_len, 32, "ScoreVersion source is 32 bytes"); assert_eq!(source_len, 32, "ScoreVersion source is 32 bytes");
// The first count prefix (pages) is a 4-byte u32 zero — not 8 bytes. // The first count prefix (pages) is a 4-byte u32 zero — not 8 bytes.
assert_eq!(&bytes[source_len..source_len + 4], &0u32.to_le_bytes()); assert_eq!(&bytes[source_len..source_len + 4], &0u32.to_le_bytes());
// The four count prefixes occupy exactly 4 × 4 bytes; then the catalog, // The five count prefixes occupy exactly 5 × 4 bytes; then the catalog,
// whose length we recompute independently (no magic number). // whose length we recompute independently (no magic number).
let catalog_len = { let catalog_len = {
let mut c = Vec::new(); let mut c = Vec::new();
@ -422,8 +438,8 @@ mod tests {
}; };
assert_eq!( assert_eq!(
bytes.len(), bytes.len(),
source_len + 4 * 4 + catalog_len, source_len + 5 * 4 + catalog_len,
"four u32 count prefixes (16 bytes), not u64 (32 bytes)" "five u32 count prefixes (20 bytes), not u64 (40 bytes)"
); );
} }

View File

@ -70,9 +70,12 @@ pub struct RoundTripReport {
pub glyphs: usize, pub glyphs: usize,
/// Stroke primitives (staff lines, stems, markers, …). /// Stroke primitives (staff lines, stems, markers, …).
pub render_strokes: usize, pub render_strokes: usize,
/// Total render primitives — glyphs **and** strokes. /// Curve primitives (slurs, …).
pub render_curves: usize,
/// Total render primitives — glyphs, strokes, **and** curves.
pub render_primitives: usize, pub render_primitives: usize,
/// Every score-graph source recovered from the RenderIR (glyphs + strokes). /// Every score-graph source recovered from the RenderIR (glyphs + strokes
/// + curves).
pub recovered_sources: BTreeSet<TypedObjectId>, pub recovered_sources: BTreeSet<TypedObjectId>,
} }
@ -161,7 +164,8 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
.glyphs .glyphs
.iter() .iter()
.map(|g| &g.provenance) .map(|g| &g.provenance)
.chain(constrained.strokes.iter().map(|s| &s.provenance)), .chain(constrained.strokes.iter().map(|s| &s.provenance))
.chain(constrained.curves.iter().map(|c| &c.provenance)),
); );
for (id, provenance) in &logical_map { for (id, provenance) in &logical_map {
assert_eq!( assert_eq!(
@ -226,6 +230,10 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
report.layout.strokes, constrained.strokes, report.layout.strokes, constrained.strokes,
"stub solver must return the input strokes verbatim" "stub solver must return the input strokes verbatim"
); );
assert_eq!(
report.layout.curves, constrained.curves,
"stub solver must return the input curves verbatim"
);
} }
let resolved_map = provenance_map( let resolved_map = provenance_map(
@ -235,7 +243,8 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
.glyphs .glyphs
.iter() .iter()
.map(|g| &g.provenance) .map(|g| &g.provenance)
.chain(report.layout.strokes.iter().map(|s| &s.provenance)), .chain(report.layout.strokes.iter().map(|s| &s.provenance))
.chain(report.layout.curves.iter().map(|c| &c.provenance)),
); );
// Every constrained object survives into the resolved layout with its exact // Every constrained object survives into the resolved layout with its exact
// provenance. A conformant solver may additionally *synthesize* objects of // provenance. A conformant solver may additionally *synthesize* objects of
@ -284,13 +293,18 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
render.strokes, report.layout.strokes, render.strokes, report.layout.strokes,
"render must carry the resolved strokes verbatim" "render must carry the resolved strokes verbatim"
); );
assert_eq!(
render.curves, report.layout.curves,
"render must carry the resolved curves verbatim"
);
let render_map = provenance_map( let render_map = provenance_map(
"render", "render",
render render
.primitives .primitives
.iter() .iter()
.map(|p| &p.provenance) .map(|p| &p.provenance)
.chain(render.strokes.iter().map(|s| &s.provenance)), .chain(render.strokes.iter().map(|s| &s.provenance))
.chain(render.curves.iter().map(|c| &c.provenance)),
); );
assert_eq!( assert_eq!(
resolved_map, render_map, resolved_map, render_map,
@ -298,23 +312,24 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
); );
// Provenance back to graph identity: the recovered source *set* — over every // Provenance back to graph identity: the recovered source *set* — over every
// primitive, glyph and stroke — is exactly the set laid out (a surjection: // primitive (glyph, stroke, and curve) — is exactly the set laid out (a
// every source recovered, nothing spurious), while the primitive count equals // surjection: every source recovered, nothing spurious), while the primitive
// the distinct-stable-id count, so each layout object (and each synthesized // count equals the distinct-stable-id count, so each layout object (and each
// derived primitive) is represented exactly once. // synthesized derived primitive) is represented exactly once.
let expected = laid_out_object_ids(score); let expected = laid_out_object_ids(score);
let recovered: BTreeSet<TypedObjectId> = render let recovered: BTreeSet<TypedObjectId> = render
.primitives .primitives
.iter() .iter()
.map(|p| p.provenance.source) .map(|p| p.provenance.source)
.chain(render.strokes.iter().map(|s| s.provenance.source)) .chain(render.strokes.iter().map(|s| s.provenance.source))
.chain(render.curves.iter().map(|c| c.provenance.source))
.collect(); .collect();
assert_eq!( assert_eq!(
expected, recovered, expected, recovered,
"RenderIR sources do not match the laid-out graph objects" "RenderIR sources do not match the laid-out graph objects"
); );
assert_eq!( assert_eq!(
render.primitives.len() + render.strokes.len(), render.primitives.len() + render.strokes.len() + render.curves.len(),
render_map.len(), render_map.len(),
"render produced two primitives with the same stable id" "render produced two primitives with the same stable id"
); );
@ -329,7 +344,8 @@ pub fn round_trip_with<S: ConstraintSolver>(score: &Score, solver: &S) -> RoundT
+ logical.cross_region.len(), + logical.cross_region.len(),
glyphs: constrained.glyphs.len(), glyphs: constrained.glyphs.len(),
render_strokes: render.strokes.len(), render_strokes: render.strokes.len(),
render_primitives: render.primitives.len() + render.strokes.len(), render_curves: render.curves.len(),
render_primitives: render.primitives.len() + render.strokes.len() + render.curves.len(),
recovered_sources: recovered, recovered_sources: recovered,
} }
} }
@ -493,7 +509,7 @@ mod tests {
let report = round_trip(&valid_score(seed)); let report = round_trip(&valid_score(seed));
assert_eq!( assert_eq!(
report.render_primitives, report.render_primitives,
report.glyphs + report.render_strokes report.glyphs + report.render_strokes + report.render_curves
); );
// Every laid-out source is recovered, nothing spurious (the // Every laid-out source is recovered, nothing spurious (the
// surjection). A source may back several primitives now — a staff's // surjection). A source may back several primitives now — a staff's

View File

@ -474,13 +474,18 @@ impl StubSolver {
} else { } else {
Vec::new() Vec::new()
}; };
// Strokes pass through verbatim (the stub resolves no geometry), gated on // Strokes and curves pass through verbatim (the stub resolves no
// the same structural validity as the glyphs. // geometry), gated on the same structural validity as the glyphs.
let strokes = if structural_valid { let strokes = if structural_valid {
input.strokes.clone() input.strokes.clone()
} else { } else {
Vec::new() Vec::new()
}; };
let curves = if structural_valid {
input.curves.clone()
} else {
Vec::new()
};
let resolved_glyphs = glyphs.len(); let resolved_glyphs = glyphs.len();
let pages = input let pages = input
.regions .regions
@ -515,6 +520,7 @@ impl StubSolver {
pages, pages,
glyphs, glyphs,
strokes, strokes,
curves,
engraving_decisions: input.engraving_decisions.clone(), engraving_decisions: input.engraving_decisions.clone(),
catalog: input.catalog.clone(), catalog: input.catalog.clone(),
}, },
@ -609,6 +615,7 @@ mod tests {
}], }],
glyphs, glyphs,
strokes: vec![], strokes: vec![],
curves: vec![],
vertical_bands: vec![band], vertical_bands: vec![band],
constraints: vec![], constraints: vec![],
break_origins: vec![], break_origins: vec![],
@ -696,6 +703,7 @@ mod tests {
}], }],
glyphs: vec![unknown], glyphs: vec![unknown],
strokes: vec![], strokes: vec![],
curves: vec![],
vertical_bands: vec![band], vertical_bands: vec![band],
constraints: vec![], constraints: vec![],
break_origins: vec![], break_origins: vec![],

View File

@ -114,3 +114,15 @@ diff is content-only). `GlyphClass` gained a `Repeat` class (token
`repeat`) so snapshots and `data-class` attributes separate repeat signs `repeat`) so snapshots and `data-class` attributes separate repeat signs
from plain barlines. Volta ending numerals arrive as `timeSig` digit glyphs from plain barlines. Volta ending numerals arrive as `timeSig` digit glyphs
(there is still no free-text primitive — unchanged). (there is still no free-text primitive — unchanged).
## Curve primitive → stroked `<path>`, `GlyphClass` unaffected (E2, 2026-07-08)
A resolved `Curve` (slur) emits a stroked, unfilled cubic-bézier `<path d="M..
C.."` with `data-kind="curve"` and a `curve_provenance_attrs` trace — drawn
after strokes and before glyphs (a slur sits over the staff lines, under the
noteheads it joins), in its own layer-grouped loop parallel to the stroke
loop. `content_bounds` grows by each curve's control-point hull ± half-thickness
(a cubic's ink never bows past its hull). `RenderStats` gained `curve_count`;
the acceptance snapshot prints it and the `provenance_count == glyph + stroke`
invariant became `+ curve`. `GlyphClass` is untouched — curves are not glyphs,
so they carry no `data-class`.

View File

@ -183,6 +183,8 @@ pub struct RenderStats {
pub fallback_rect_count: usize, pub fallback_rect_count: usize,
/// `<line>` elements emitted (one per resolved stroke: staff line, stem, …). /// `<line>` elements emitted (one per resolved stroke: staff line, stem, …).
pub stroke_count: usize, pub stroke_count: usize,
/// `<path>` curve elements emitted (one per resolved curve: slur, …).
pub curve_count: usize,
/// Elements carrying a `data-prov` trace back to a score-graph source. /// Elements carrying a `data-prov` trace back to a score-graph source.
pub provenance_count: usize, pub provenance_count: usize,
/// Distinct layers, each rendered as one `<g>` group. /// Distinct layers, each rendered as one `<g>` group.
@ -239,6 +241,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut
text_count: 0, text_count: 0,
fallback_rect_count: 0, fallback_rect_count: 0,
stroke_count: 0, stroke_count: 0,
curve_count: 0,
provenance_count: 0, provenance_count: 0,
layer_count: 0, layer_count: 0,
class_counts, class_counts,
@ -262,9 +265,14 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut
for (i, stroke) in resolved.strokes.iter().enumerate() { for (i, stroke) in resolved.strokes.iter().enumerate() {
stroke_layers.entry(stroke.layer).or_default().push(i); stroke_layers.entry(stroke.layer).or_default().push(i);
} }
let mut curve_layers: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
for (i, curve) in resolved.curves.iter().enumerate() {
curve_layers.entry(curve.layer).or_default().push(i);
}
let layer_ids: std::collections::BTreeSet<i32> = glyph_layers let layer_ids: std::collections::BTreeSet<i32> = glyph_layers
.keys() .keys()
.chain(stroke_layers.keys()) .chain(stroke_layers.keys())
.chain(curve_layers.keys())
.copied() .copied()
.collect(); .collect();
@ -272,6 +280,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut
let mut text_count = 0; let mut text_count = 0;
let mut fallback_rect_count = 0; let mut fallback_rect_count = 0;
let mut stroke_count = 0; let mut stroke_count = 0;
let mut curve_count = 0;
let mut provenance_count = 0; let mut provenance_count = 0;
let mut s = String::new(); let mut s = String::new();
@ -345,6 +354,39 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut
} }
} }
// Curves (slurs, …) — drawn after strokes, before glyphs, as a stroked
// (unfilled) cubic-bézier `<path>`.
if let Some(indices) = curve_layers.get(layer) {
for &i in indices {
let curve = &resolved.curves[i];
let (stroke_fill, opacity) = stroke_colour(curve.style.rgba);
let prov = if options.emit_provenance {
provenance_count += 1;
curve_provenance_attrs(&curve.provenance)
} else {
String::new()
};
curve_count += 1;
let _ = writeln!(
s,
" <path d=\"M {} {} C {} {} {} {} {} {}\" \
fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}{}/>",
num(curve.p0.x.0),
num(curve.p0.y.0),
num(curve.p1.x.0),
num(curve.p1.y.0),
num(curve.p2.x.0),
num(curve.p2.y.0),
num(curve.p3.x.0),
num(curve.p3.y.0),
stroke_fill,
num(curve.thickness.0),
opacity,
prov,
);
}
}
if let Some(indices) = glyph_layers.get(layer) { if let Some(indices) = glyph_layers.get(layer) {
for &i in indices { for &i in indices {
let g = &resolved.glyphs[i]; let g = &resolved.glyphs[i];
@ -440,6 +482,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut
text_count, text_count,
fallback_rect_count, fallback_rect_count,
stroke_count, stroke_count,
curve_count,
provenance_count, provenance_count,
layer_count: layer_ids.len(), layer_count: layer_ids.len(),
class_counts, class_counts,
@ -497,6 +540,24 @@ fn content_bounds(resolved: &ResolvedLayoutIR, margin: f32) -> Option<(f32, f32,
} }
} }
} }
// A cubic bézier's ink stays within its control points' convex hull, so
// bounding by the four control points (± half-thickness) is correct and
// conservative — the drawn arc never bows past it.
for curve in &resolved.curves {
let half = (curve.thickness.0 * 0.5).max(0.0);
for point in curve.control_points() {
let (cx, cy) = (point.x.0, point.y.0);
for (px, py) in [(cx - half, cy - half), (cx + half, cy + half)] {
if px.is_finite() && py.is_finite() {
any = true;
min_x = min_x.min(px);
min_y = min_y.min(py);
max_x = max_x.max(px);
max_y = max_y.max(py);
}
}
}
}
if !any { if !any {
return None; return None;
} }
@ -539,6 +600,15 @@ fn stroke_provenance_attrs(p: &Provenance) -> String {
) )
} }
/// `data-*` provenance attributes for a curve (a cubic-bézier primitive).
fn curve_provenance_attrs(p: &Provenance) -> String {
format!(
" data-prov=\"{:032x}\" data-source-kind=\"{}\" data-kind=\"curve\"",
p.stable_id.0,
p.source.discriminant(),
)
}
/// Whether a [`Transform2D`] is a pure 2-D affine — its bottom row is `[0, 0, 1]` /// Whether a [`Transform2D`] is a pure 2-D affine — its bottom row is `[0, 0, 1]`
/// (within an `f32` tolerance). SVG transforms are affine, so a non-affine /// (within an `f32` tolerance). SVG transforms are affine, so a non-affine
/// (projective) transform cannot be represented; the renderer diagnoses it and /// (projective) transform cannot be represented; the renderer diagnoses it and
@ -643,7 +713,7 @@ fn glyph_note(mode: GlyphMode) -> &'static str {
/// archival. Shared by the main render and [`empty_svg`] so neither can drift. /// archival. Shared by the main render and [`empty_svg`] so neither can drift.
fn provenance_note(emit_provenance: bool) -> &'static str { fn provenance_note(emit_provenance: bool) -> &'static str {
if emit_provenance { if emit_provenance {
"every glyph and stroke carries a data-prov trace to its score-graph source" "every glyph, stroke, and curve carries a data-prov trace to its score-graph source"
} else { } else {
"provenance traces suppressed (display-only output, not archival)" "provenance traces suppressed (display-only output, not archival)"
} }
@ -925,6 +995,7 @@ mod tests {
pages: vec![], pages: vec![],
glyphs: vec![], glyphs: vec![],
strokes: vec![], strokes: vec![],
curves: vec![],
engraving_decisions: vec![], engraving_decisions: vec![],
catalog: Default::default(), catalog: Default::default(),
}; };

View File

@ -41,6 +41,10 @@ fn fixtures() -> Vec<(&'static str, Score)> {
"ten_measure_with_repeats", "ten_measure_with_repeats",
epiphany_testkit::fixtures::ten_measure_with_repeats(0x000A_11CE), epiphany_testkit::fixtures::ten_measure_with_repeats(0x000A_11CE),
), ),
(
"ten_measure_with_slurs",
epiphany_testkit::fixtures::ten_measure_with_slurs(0x000A_11CE),
),
] ]
} }
@ -70,6 +74,7 @@ fn snapshot_text(
out.stats.fallback_rect_count out.stats.fallback_rect_count
)); ));
s.push_str(&format!("stroke_count={}\n", out.stats.stroke_count)); s.push_str(&format!("stroke_count={}\n", out.stats.stroke_count));
s.push_str(&format!("curve_count={}\n", out.stats.curve_count));
s.push_str(&format!( s.push_str(&format!(
"provenance_count={}\n", "provenance_count={}\n",
out.stats.provenance_count out.stats.provenance_count
@ -146,8 +151,8 @@ fn fixtures_render_to_golden_locked_svg_and_snapshot() {
); );
assert_eq!( assert_eq!(
out.stats.provenance_count, out.stats.provenance_count,
out.stats.glyph_count + out.stats.stroke_count, out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count,
"{fixture}: every drawn glyph and stroke must carry a provenance trace" "{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace"
); );
let class_sum: usize = out.stats.class_counts.values().sum(); let class_sum: usize = out.stats.class_counts.values().sum();
assert_eq!( assert_eq!(
@ -310,8 +315,8 @@ fn engraver_output_is_golden_locked_well_formed_with_every_glyph_drawn() {
); );
assert_eq!( assert_eq!(
out.stats.provenance_count, out.stats.provenance_count,
out.stats.glyph_count + out.stats.stroke_count, out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count,
"{fixture}: every drawn glyph and stroke must carry a provenance trace" "{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace"
); );
assert!( assert!(
out.diagnostics.is_empty(), out.diagnostics.is_empty(),

View File

@ -3,6 +3,7 @@ glyph_count=51
path_count=51 path_count=51
fallback_rect_count=0 fallback_rect_count=0
stroke_count=96 stroke_count=96
curve_count=0
provenance_count=147 provenance_count=147
layer_count=1 layer_count=1
hard_constraint_count=90 hard_constraint_count=90

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="649.4532" height="205.8981" viewBox="0 0 64.9453 20.5898"> <svg xmlns="http://www.w3.org/2000/svg" width="649.4532" height="205.8981" viewBox="0 0 64.9453 20.5898">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(-5.5 -5.5006) scale(1 -1)"> <g transform="translate(-5.5 -5.5006) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="8.5599" y1="-12.8926" x2="8.5599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/> <line x1="8.5599" y1="-12.8926" x2="8.5599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -3,6 +3,7 @@ glyph_count=51
path_count=51 path_count=51
fallback_rect_count=0 fallback_rect_count=0
stroke_count=91 stroke_count=91
curve_count=0
provenance_count=142 provenance_count=142
layer_count=1 layer_count=1
hard_constraint_count=90 hard_constraint_count=90

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="888.7701" height="110.24" viewBox="0 0 88.877 11.024"> <svg xmlns="http://www.w3.org/2000/svg" width="888.7701" height="110.24" viewBox="0 0 88.877 11.024">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(3.065 7.392) scale(1 -1)"> <g transform="translate(3.065 7.392) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/> <line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -3,6 +3,7 @@ glyph_count=56
path_count=56 path_count=56
fallback_rect_count=0 fallback_rect_count=0
stroke_count=105 stroke_count=105
curve_count=0
provenance_count=161 provenance_count=161
layer_count=1 layer_count=1
hard_constraint_count=95 hard_constraint_count=95

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="670.3889" height="231.0481" viewBox="0 0 67.0389 23.1048"> <svg xmlns="http://www.w3.org/2000/svg" width="670.3889" height="231.0481" viewBox="0 0 67.0389 23.1048">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(-5.5 -5.5006) scale(1 -1)"> <g transform="translate(-5.5 -5.5006) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="8.5599" y1="-12.8926" x2="8.5599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/> <line x1="8.5599" y1="-12.8926" x2="8.5599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -3,6 +3,7 @@ glyph_count=56
path_count=56 path_count=56
fallback_rect_count=0 fallback_rect_count=0
stroke_count=100 stroke_count=100
curve_count=0
provenance_count=156 provenance_count=156
layer_count=1 layer_count=1
hard_constraint_count=95 hard_constraint_count=95

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="929.3745" height="122.12" viewBox="0 0 92.9375 12.212"> <svg xmlns="http://www.w3.org/2000/svg" width="929.3745" height="122.12" viewBox="0 0 92.9375 12.212">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(3.065 8.58) scale(1 -1)"> <g transform="translate(3.065 8.58) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/> <line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -0,0 +1,15 @@
fixture=ten_measure_with_slurs solver=engrave
glyph_count=51
path_count=51
fallback_rect_count=0
stroke_count=96
curve_count=3
provenance_count=150
layer_count=1
hard_constraint_count=90
xml_well_formed=true
view_box=[5.4999986 -28.318335 64.94533 22.818335]
class_counts:
barline=10
clef=1
notehead=40

View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="649.4532" height="228.1833" viewBox="0 0 64.9453 22.8183">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(-5.5 -5.5) scale(1 -1)">
<g data-layer="0">
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>
<line x1="7.565" y1="-13.3267" x2="68.3803" y2="-13.3267" stroke="#000000" stroke-width="0.13" data-prov="5235156954d3cdda987a3da1af222a55" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-12.3267" x2="68.3803" y2="-12.3267" stroke="#000000" stroke-width="0.13" data-prov="10cbe3e182e937b8e3d90a03397b2e97" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-11.3267" x2="68.3803" y2="-11.3267" stroke="#000000" stroke-width="0.13" data-prov="94825cd467fe6fb512d271e66b59674a" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-10.3267" x2="68.3803" y2="-10.3267" stroke="#000000" stroke-width="0.13" data-prov="bc2cd26cec8e4650eedcf04fbc91320f" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-9.3267" x2="68.3803" y2="-9.3267" stroke="#000000" stroke-width="0.13" data-prov="c46b9445a4c5ff909f50b13f9025c4a3" data-source-kind="3" data-kind="stroke"/>
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="fe116eb75ff697f9f2451c10c05349c6" data-source-kind="2" data-kind="stroke"/>
<line x1="14.5399" y1="-14.3267" x2="14.5399" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="dbd573fbc0c21f9b07a8537f658b048c" data-source-kind="0" data-kind="stroke"/>
<line x1="12.7444" y1="-14.3267" x2="14.5251" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="07ffb45f25e59b0955bd4e248522b2ef" data-source-kind="1" data-kind="stroke"/>
<line x1="16.6206" y1="-14.3267" x2="16.6206" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="276b6f72f2bcb818f84229b6833ce022" data-source-kind="0" data-kind="stroke"/>
<line x1="14.8251" y1="-14.3267" x2="16.6058" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="3539af5cd23dc6ca259a35fef4aa45c2" data-source-kind="1" data-kind="stroke"/>
<line x1="18.7012" y1="-14.3267" x2="18.7012" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="d1b0067ebb2b2b9115a77aaadc53bffe" data-source-kind="0" data-kind="stroke"/>
<line x1="16.9058" y1="-14.3267" x2="18.6864" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="9b43d5d2a83ce1086364f6f3ac4b5ba2" data-source-kind="1" data-kind="stroke"/>
<line x1="20.5663" y1="-14.3267" x2="20.5663" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="13303d184ca25b99835d71e21f3a96df" data-source-kind="0" data-kind="stroke"/>
<line x1="18.9864" y1="-14.3267" x2="20.7671" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="d6c7a9458a06532e1c7507e71363c0dd" data-source-kind="1" data-kind="stroke"/>
<line x1="24.0626" y1="-14.3267" x2="24.0626" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="aa33134cf72cebda65d1b2b427230541" data-source-kind="0" data-kind="stroke"/>
<line x1="22.2671" y1="-14.3267" x2="24.0477" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="20fbe0162b0bc4dd93e2c3e3da73bb83" data-source-kind="1" data-kind="stroke"/>
<line x1="26.1432" y1="-14.3267" x2="26.1432" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="01d06ae931cf05983e4b17744108297b" data-source-kind="0" data-kind="stroke"/>
<line x1="24.3477" y1="-14.3267" x2="26.1284" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="cd9abf9ed66ab876f68b9d88ce1e5199" data-source-kind="1" data-kind="stroke"/>
<line x1="28.2239" y1="-14.3267" x2="28.2239" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="47c9ce76466974ecfabe4a9cafb75c3c" data-source-kind="0" data-kind="stroke"/>
<line x1="26.4284" y1="-14.3267" x2="28.2091" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="7ade41165c11102f29e87cb09eb3f88f" data-source-kind="1" data-kind="stroke"/>
<line x1="30.0889" y1="-14.3267" x2="30.0889" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="e6d1c0a44d13be2700ad4381e1d518fa" data-source-kind="0" data-kind="stroke"/>
<line x1="28.5091" y1="-14.3267" x2="30.2897" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="32c2aa89808756c46e8b7afcfb2eb057" data-source-kind="1" data-kind="stroke"/>
<line x1="33.5852" y1="-14.3267" x2="33.5852" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="370a65aab516f6792e49a1ed28bd5cd9" data-source-kind="0" data-kind="stroke"/>
<line x1="31.7897" y1="-14.3267" x2="33.5704" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="0a3ae3c155d29c21e266e663a8b1b214" data-source-kind="1" data-kind="stroke"/>
<line x1="35.6659" y1="-14.3267" x2="35.6659" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="75b4bca159d5c28b10b2078bb457bb53" data-source-kind="0" data-kind="stroke"/>
<line x1="33.8704" y1="-14.3267" x2="35.6511" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="c27e3e835224dc54cef9b36818099cd0" data-source-kind="1" data-kind="stroke"/>
<line x1="37.7465" y1="-14.3267" x2="37.7465" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="c4531390972e55a43af2bd9f85e3c250" data-source-kind="0" data-kind="stroke"/>
<line x1="35.9511" y1="-14.3267" x2="37.7317" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="4b43990bc25bf4afffda3e2a7d4dcab7" data-source-kind="1" data-kind="stroke"/>
<line x1="39.6116" y1="-14.3267" x2="39.6116" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="888c6ef019fc4b0fc714672a4444c60c" data-source-kind="0" data-kind="stroke"/>
<line x1="38.0317" y1="-14.3267" x2="39.8124" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="8e120598410629bb86883f318071a6b5" data-source-kind="1" data-kind="stroke"/>
<line x1="43.1079" y1="-14.3267" x2="43.1079" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="6c70ad9006ed8fbb104d77eba3f2dbcb" data-source-kind="0" data-kind="stroke"/>
<line x1="41.3124" y1="-14.3267" x2="43.093" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="6d2724bb96160f831b4e987ce6b4349a" data-source-kind="1" data-kind="stroke"/>
<line x1="45.1885" y1="-14.3267" x2="45.1885" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="debe82da2c34817a871d8a4cc5cc3fdd" data-source-kind="0" data-kind="stroke"/>
<line x1="43.393" y1="-14.3267" x2="45.1737" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="7a9f84c8590c7e8156fd929e35153cf8" data-source-kind="1" data-kind="stroke"/>
<line x1="47.2692" y1="-14.3267" x2="47.2692" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="7b524c2e9e5657b68fd1a81337887c2e" data-source-kind="0" data-kind="stroke"/>
<line x1="45.4737" y1="-14.3267" x2="47.2544" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="c4a9602f838e0ea74a9fe68da12baff2" data-source-kind="1" data-kind="stroke"/>
<line x1="49.1342" y1="-14.3267" x2="49.1342" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="cb31fbbd4a3e3eb17ed06e65f40589e5" data-source-kind="0" data-kind="stroke"/>
<line x1="47.5544" y1="-14.3267" x2="49.335" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="76860486e62be8520184bb3e8c284d94" data-source-kind="1" data-kind="stroke"/>
<line x1="52.6305" y1="-14.3267" x2="52.6305" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="bc1cc9bede7fc3c72827cfbf184cdb7e" data-source-kind="0" data-kind="stroke"/>
<line x1="50.835" y1="-14.3267" x2="52.6157" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="a3fd2ae47f432e61881cb1e44a7108e6" data-source-kind="1" data-kind="stroke"/>
<line x1="54.7112" y1="-14.3267" x2="54.7112" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="95891bc2b58231a1618b5d422e6131b0" data-source-kind="0" data-kind="stroke"/>
<line x1="52.9157" y1="-14.3267" x2="54.6964" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="a9e2ee2be83c0a4978db517bf182efe9" data-source-kind="1" data-kind="stroke"/>
<line x1="56.7918" y1="-14.3267" x2="56.7918" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="ed21002861831422bdbb36e8f981d2a9" data-source-kind="0" data-kind="stroke"/>
<line x1="54.9964" y1="-14.3267" x2="56.777" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="2bac60a88ddddcf403dd6edfb67b5bb0" data-source-kind="1" data-kind="stroke"/>
<line x1="58.6569" y1="-14.3267" x2="58.6569" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="e49748251746a6dbdf15aa8dd983e6e7" data-source-kind="0" data-kind="stroke"/>
<line x1="57.077" y1="-14.3267" x2="58.8577" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="a592bc951f4afa64726aa30ccdd6ddfa" data-source-kind="1" data-kind="stroke"/>
<line x1="62.1532" y1="-14.3267" x2="62.1532" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="8d2938a01abfe451991829fbde6d2b94" data-source-kind="0" data-kind="stroke"/>
<line x1="60.3577" y1="-14.3267" x2="62.1383" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="c73f1aad686621adde07e7fe7b160ad7" data-source-kind="1" data-kind="stroke"/>
<line x1="64.2338" y1="-14.3267" x2="64.2338" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="88366a6edcce03db41d39d08b2616742" data-source-kind="0" data-kind="stroke"/>
<line x1="62.4383" y1="-14.3267" x2="64.219" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="4e052448582a8fdc672f38b02dc205e1" data-source-kind="1" data-kind="stroke"/>
<line x1="66.3145" y1="-14.3267" x2="66.3145" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="77a10c7862f0ffce98d309bb71ce675f" data-source-kind="0" data-kind="stroke"/>
<line x1="64.519" y1="-14.3267" x2="66.2997" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="68c691f2336e5a322116641b9f2f7054" data-source-kind="1" data-kind="stroke"/>
<line x1="68.1795" y1="-14.3267" x2="68.1795" y2="-10.8267" stroke="#000000" stroke-width="0.12" data-prov="cfa457e70519a1826f321a56cb3fd03b" data-source-kind="0" data-kind="stroke"/>
<line x1="66.5997" y1="-14.3267" x2="68.3803" y2="-14.3267" stroke="#000000" stroke-width="0.13" data-prov="3c3f0d6b5ab00374d7628cd6f9876bc9" data-source-kind="1" data-kind="stroke"/>
<line x1="10.5605" y1="-25.8183" x2="10.5605" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="273d04eeece605789035a29d80023c2c" data-source-kind="0" data-kind="stroke"/>
<line x1="8.765" y1="-25.8183" x2="10.5457" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="9ec226573f8ea2320fc444c562163ddb" data-source-kind="1" data-kind="stroke"/>
<line x1="12.6411" y1="-25.8183" x2="12.6411" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="a23b09fda2784b566bf1de48e89a2baa" data-source-kind="0" data-kind="stroke"/>
<line x1="10.8457" y1="-25.8183" x2="12.6263" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="9e3345d5dddffbb9cf263a1708fc2589" data-source-kind="1" data-kind="stroke"/>
<line x1="14.7218" y1="-25.8183" x2="14.7218" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="edf9ca4611cfc17faace6241cae3b73d" data-source-kind="0" data-kind="stroke"/>
<line x1="12.9263" y1="-25.8183" x2="14.707" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="ea06a5b83026202fbe3eed55ca3680c9" data-source-kind="1" data-kind="stroke"/>
<line x1="16.5868" y1="-25.8183" x2="16.5868" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="f1e3c0367633266d5f188e5bd6d98ad4" data-source-kind="0" data-kind="stroke"/>
<line x1="15.007" y1="-25.8183" x2="16.7877" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="b8d324f0620ebb59f66346b61a4d214e" data-source-kind="1" data-kind="stroke"/>
<line x1="20.0831" y1="-25.8183" x2="20.0831" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="2bf824905032462ee8c9988b780feabf" data-source-kind="0" data-kind="stroke"/>
<line x1="18.2877" y1="-25.8183" x2="20.0683" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="579f0f80918268f1ba03890d14832b56" data-source-kind="1" data-kind="stroke"/>
<line x1="22.1638" y1="-25.8183" x2="22.1638" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="08a65aebf8baaded74feb3ec7cc36d18" data-source-kind="0" data-kind="stroke"/>
<line x1="20.3683" y1="-25.8183" x2="22.149" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="f04f04c436808c53ab1c2b169d7f5a33" data-source-kind="1" data-kind="stroke"/>
<line x1="24.2445" y1="-25.8183" x2="24.2445" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="d155779804ad660f4466745153872349" data-source-kind="0" data-kind="stroke"/>
<line x1="22.449" y1="-25.8183" x2="24.2297" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="3b9174fe7361dbfc454fbe17b9ec81b1" data-source-kind="1" data-kind="stroke"/>
<line x1="26.1095" y1="-25.8183" x2="26.1095" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="af73177552d5fa95b746caf62d000623" data-source-kind="0" data-kind="stroke"/>
<line x1="24.5296" y1="-25.8183" x2="26.3103" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="46e6eac9f03c1e09a15cb0bb07628570" data-source-kind="1" data-kind="stroke"/>
<line x1="29.6058" y1="-25.8183" x2="29.6058" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="7c839e4fb5f2522c8fde3b48577eafae" data-source-kind="0" data-kind="stroke"/>
<line x1="27.8103" y1="-25.8183" x2="29.591" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="0e4ec9f37979b06c54f0e2d499df5bf1" data-source-kind="1" data-kind="stroke"/>
<line x1="31.6865" y1="-25.8183" x2="31.6865" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="0c8e0b8170c827494e606d4541ce2c89" data-source-kind="0" data-kind="stroke"/>
<line x1="29.891" y1="-25.8183" x2="31.6717" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="61f1ed037baf82bf6594769a9205df07" data-source-kind="1" data-kind="stroke"/>
<line x1="33.7672" y1="-25.8183" x2="33.7672" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="42480ffc14ad129d8dec456e7bad317c" data-source-kind="0" data-kind="stroke"/>
<line x1="31.9717" y1="-25.8183" x2="33.7523" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="adc82c9de5d3ed333e335c32f7690a6b" data-source-kind="1" data-kind="stroke"/>
<line x1="35.8478" y1="-25.8183" x2="35.8478" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="0f408610c63793003f04e3af5a03493f" data-source-kind="0" data-kind="stroke"/>
<line x1="34.0523" y1="-25.8183" x2="35.833" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="1dee0e17b4b9167cda8e51b45e2ab47c" data-source-kind="1" data-kind="stroke"/>
<line x1="37.9285" y1="-25.8183" x2="37.9285" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="99d50b32a7d1eac1019c14132a834144" data-source-kind="0" data-kind="stroke"/>
<line x1="36.133" y1="-25.8183" x2="37.9137" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="c33f0e929fa3ea4230c07aca80f60c04" data-source-kind="1" data-kind="stroke"/>
<line x1="40.0092" y1="-25.8183" x2="40.0092" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="572e96450f21b18771e510c6c41444dc" data-source-kind="0" data-kind="stroke"/>
<line x1="38.2137" y1="-25.8183" x2="39.9944" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="67463b29bfb160641501f497f6134aa6" data-source-kind="1" data-kind="stroke"/>
<line x1="42.0899" y1="-25.8183" x2="42.0899" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="68935c410dbe357c3f80b01c6e6d3cb1" data-source-kind="0" data-kind="stroke"/>
<line x1="40.2944" y1="-25.8183" x2="42.075" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="75f41055f9e23911ca51158dab6488df" data-source-kind="1" data-kind="stroke"/>
<line x1="43.3356" y1="-25.8183" x2="43.3356" y2="-22.3183" stroke="#000000" stroke-width="0.12" data-prov="9e9ba65603d31963bdc6d55dfacc5eba" data-source-kind="0" data-kind="stroke"/>
<line x1="42.375" y1="-25.8183" x2="44.1557" y2="-25.8183" stroke="#000000" stroke-width="0.13" data-prov="66a37f88caecfe799ec34469d2302afd" data-source-kind="1" data-kind="stroke"/>
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="40a9a55985c8bf8b1eb66b6d8c8e7b7a" data-source-kind="12" data-kind="stroke"/>
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="10eb9773d25f0d7d64f92b36f88e5b38" data-source-kind="14" data-kind="stroke"/>
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="86a982398112115027c54222505dd143" data-source-kind="15" data-kind="stroke"/>
<line x1="8.5599" y1="-13.3267" x2="8.5599" y2="-13.3267" stroke="#000000" stroke-width="0" data-prov="c183669ac91f35eb7a0040b48fde85ab" data-source-kind="25" data-kind="stroke"/>
<line x1="7.565" y1="-24.8183" x2="44.7429" y2="-24.8183" stroke="#000000" stroke-width="0.13" data-prov="e5c54b1ebf58ed5292d1b98f73ad084e" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-23.8183" x2="44.7429" y2="-23.8183" stroke="#000000" stroke-width="0.13" data-prov="9ee2d26461a7bc2f28eaf20e07b55ee0" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-22.8183" x2="44.7429" y2="-22.8183" stroke="#000000" stroke-width="0.13" data-prov="c5feea54a17beefce1088337f84e39fc" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-21.8183" x2="44.7429" y2="-21.8183" stroke="#000000" stroke-width="0.13" data-prov="688349b3e25cac395b2dcca6e297dcfe" data-source-kind="3" data-kind="stroke"/>
<line x1="7.565" y1="-20.8183" x2="44.7429" y2="-20.8183" stroke="#000000" stroke-width="0.13" data-prov="c146782303bbe4c29c6228235090facf" data-source-kind="3" data-kind="stroke"/>
<path d="M 13.8247 -8.6267 C 15.3852 -7.56 16.9457 -7.56 18.5062 -8.6267" fill="none" stroke="#000000" stroke-width="0.12" data-prov="cf7d96019ed88156f3cc33bf116fbae3" data-source-kind="11" data-kind="curve"/>
<path d="M 25.428 -14.0267 C 27.682 -16.6933 29.7736 -16.6933 31.5272 -14.0267" fill="none" stroke="#000000" stroke-width="0.12" data-prov="c366aecd1ef6e40cb1b3ffd2100eb3d8" data-source-kind="11" data-kind="curve"/>
<path d="M 37.0313 -8.6267 C 38.5543 -7.56 39.8898 -7.56 41.0499 -8.6267" fill="none" stroke="#000000" stroke-width="0.12" data-prov="b6c086f751dd21ffa2520010201f057f" data-source-kind="11" data-kind="curve"/>
<path d="M1.504 1.66C1.496 1.708 1.504 1.712 1.528 1.736C1.96 2.14 2.288 2.648 2.288 3.26C2.288 3.608 2.192 3.952 2.028 4.192C1.968 4.28 1.864 4.392 1.82 4.392C1.764 4.392 1.64 4.288 1.56 4.2C1.264 3.872 1.168 3.372 1.168 2.956C1.168 2.724 1.196 2.464 1.224 2.3C1.232 2.252 1.236 2.244 1.188 2.204C0.612 1.728 0 1.156 0 0.348C0 -0.348 0.476 -1.008 1.456 -1.008C1.548 -1.008 1.652 -1 1.732 -0.984C1.776 -0.976 1.784 -0.972 1.792 -1.02C1.84 -1.288 1.9 -1.636 1.9 -1.824C1.9 -2.416 1.5 -2.488 1.264 -2.488C1.048 -2.488 0.944 -2.424 0.944 -2.372C0.944 -2.344 0.98 -2.332 1.072 -2.304C1.196 -2.268 1.34 -2.16 1.34 -1.928C1.34 -1.708 1.2 -1.52 0.956 -1.52C0.688 -1.52 0.528 -1.732 0.528 -1.98C0.528 -2.24 0.684 -2.632 1.288 -2.632C1.556 -2.632 2.076 -2.512 2.076 -1.832C2.076 -1.604 2.004 -1.224 1.96 -0.976C1.952 -0.928 1.956 -0.932 2.012 -0.908C2.416 -0.748 2.684 -0.408 2.684 0.044C2.684 0.556 2.308 1.008 1.72 1.008C1.616 1.008 1.616 1.008 1.604 1.08ZM1.88 3.772C2.012 3.772 2.12 3.664 2.12 3.444C2.12 3 1.74 2.64 1.424 2.364C1.396 2.34 1.38 2.344 1.372 2.396C1.356 2.5 1.348 2.636 1.348 2.764C1.348 3.388 1.636 3.772 1.88 3.772ZM1.444 1.048C1.456 0.972 1.456 0.976 1.384 0.952C1.032 0.832 0.804 0.516 0.804 0.176C0.804 -0.184 0.992 -0.44 1.264 -0.532C1.296 -0.544 1.344 -0.556 1.372 -0.556C1.404 -0.556 1.42 -0.536 1.42 -0.512C1.42 -0.484 1.388 -0.472 1.36 -0.46C1.192 -0.388 1.072 -0.216 1.072 -0.032C1.072 0.196 1.228 0.368 1.472 0.436C1.536 0.452 1.544 0.448 1.552 0.404L1.752 -0.788C1.76 -0.832 1.756 -0.832 1.696 -0.844C1.632 -0.856 1.552 -0.864 1.472 -0.864C0.772 -0.864 0.32 -0.476 0.32 0.08C0.32 0.316 0.36 0.632 0.692 1.008C0.932 1.276 1.116 1.424 1.304 1.576C1.344 1.608 1.352 1.604 1.36 1.56ZM1.72 0.412C1.712 0.46 1.716 0.472 1.764 0.468C2.088 0.44 2.356 0.168 2.356 -0.184C2.356 -0.436 2.204 -0.64 1.98 -0.752C1.932 -0.776 1.924 -0.776 1.916 -0.728Z" transform="translate(8.5599 -12.3267)" fill="#000000" data-prov="2bc09f9490decea3e13f24459f1b64f4" data-source-kind="4" data-glyph="gClef" data-class="clef"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(13.0444 -14.3267)" fill="#000000" data-prov="870b54f8730bed8251f4f891a3c4b233" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(15.1251 -14.3267)" fill="#000000" data-prov="5833135b23eaf581bccad90222f64e7e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(17.2058 -14.3267)" fill="#000000" data-prov="3c653d764b31b22273540a079363370c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(19.2864 -14.3267)" fill="#000000" data-prov="dfd195901678557bdc1f1351634d2b68" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(22.5671 -14.3267)" fill="#000000" data-prov="99d206152de1651afeef963c42d7984f" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(24.6477 -14.3267)" fill="#000000" data-prov="1340dc8c93d5925410da02d683dd5916" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(26.7284 -14.3267)" fill="#000000" data-prov="d9b7e874516d265e3c165a16887a22bd" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(28.8091 -14.3267)" fill="#000000" data-prov="12ef29aff1cc56481133aec42c0d1d35" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(32.0897 -14.3267)" fill="#000000" data-prov="6f2586548ccd7de6800f74edd852d03b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(34.1704 -14.3267)" fill="#000000" data-prov="6cd2fcd32ca7d1441b6534c025d7ae93" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(36.2511 -14.3267)" fill="#000000" data-prov="e71f03f7d3d50a64ea72ce4bcdffb03e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(38.3317 -14.3267)" fill="#000000" data-prov="0227904e88f6312904444c17ae540b57" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(41.6124 -14.3267)" fill="#000000" data-prov="6cce2821ccbb4f5769708cb9ca3c38a7" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(43.693 -14.3267)" fill="#000000" data-prov="9952a70d5e0f9911d93466e83edc252e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(45.7737 -14.3267)" fill="#000000" data-prov="1ead59d59c0c7cec46071003e7741905" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(47.8544 -14.3267)" fill="#000000" data-prov="4b82f873de97ed4fa80748acbf368585" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(51.135 -14.3267)" fill="#000000" data-prov="a26098a444bb635ce82ff700f48491ed" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(53.2157 -14.3267)" fill="#000000" data-prov="460c0831f9fd8de9b01a3a4bac641c8e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(55.2964 -14.3267)" fill="#000000" data-prov="50dc15968bfd55c7637d0dde7c2f7bb3" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(57.377 -14.3267)" fill="#000000" data-prov="d095c952d8865af497a68b9fb543c15e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(60.6577 -14.3267)" fill="#000000" data-prov="1e4cb41d3c7556e314182c82b7790bef" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(62.7383 -14.3267)" fill="#000000" data-prov="9645397274cb84055aeba5639f8b06a5" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(64.819 -14.3267)" fill="#000000" data-prov="9578182658c00300531491532d13f66a" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(66.8997 -14.3267)" fill="#000000" data-prov="0a4dcf79021a9ba0366091e34fab456c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(9.065 -25.8183)" fill="#000000" data-prov="4b3905877c48341998d76f07cdb9eff5" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(11.1457 -25.8183)" fill="#000000" data-prov="4582d540c93386289e704713ab2deeeb" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(13.2263 -25.8183)" fill="#000000" data-prov="b6d88aa94cc5462fed11ef21a9b9d6df" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(15.307 -25.8183)" fill="#000000" data-prov="88fff9d9a3f4097be33105eff4f0664a" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(18.5877 -25.8183)" fill="#000000" data-prov="1c0a15caddb0e912285270f67ff23a3b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(20.6683 -25.8183)" fill="#000000" data-prov="82e4c22515ed591ba37576873988586e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(22.749 -25.8183)" fill="#000000" data-prov="6304bcb23912da87e33c77509f7985f4" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(24.8297 -25.8183)" fill="#000000" data-prov="99878edbfc3bbb1e8eca463cccd584ce" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(28.1103 -25.8183)" fill="#000000" data-prov="d10593c1a375a3b07eb107dae9e897b3" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(30.191 -25.8183)" fill="#000000" data-prov="ba9722cd8f95336b0804751c84983573" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(32.2717 -25.8183)" fill="#000000" data-prov="103fdcd52932b1c8f5fab0036c2dd002" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(34.3523 -25.8183)" fill="#000000" data-prov="fcd49faa62e91333932dbac75206c859" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(36.433 -25.8183)" fill="#000000" data-prov="cc13049cf24d89de78d5dcc3bed18d7f" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(38.5137 -25.8183)" fill="#000000" data-prov="88a15f00dc6e06aaf9f2532f05f6197e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(40.5944 -25.8183)" fill="#000000" data-prov="cd16148313e9a4f391ab0cabfeb1ee4c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(42.675 -25.8183)" fill="#000000" data-prov="df598a181d9acccffc346705fce5da9b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.144 0V4H0V0Z" transform="translate(11.5444 -13.3267)" fill="#000000" data-prov="867b15e7cfaeccccfe301517166fe106" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(21.0671 -13.3267)" fill="#000000" data-prov="2d8913235b36cc0b1c41dc5238cba014" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(30.5897 -13.3267)" fill="#000000" data-prov="b263edd8a4514fa237d4e7942c04be8c" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(40.1124 -13.3267)" fill="#000000" data-prov="abc1bd57e0116cfc47c4f84f0c22b2e6" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(49.635 -13.3267)" fill="#000000" data-prov="9f811a0578b546d35393e29bb06afe9b" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(59.1577 -13.3267)" fill="#000000" data-prov="7c62f742d0a60d8a6177a714ef9241af" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(7.565 -24.8183)" fill="#000000" data-prov="0b1e63cbd07616159ee70000e3c76056" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(17.0877 -24.8183)" fill="#000000" data-prov="f21f4e337050d50f36f7d905196db794" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(26.6103 -24.8183)" fill="#000000" data-prov="f4a3968aa56472207e4e921c44dfa1d7" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0ZM0.912 4H0.412V0H0.912Z" transform="translate(44.4557 -24.8183)" fill="#000000" data-prov="d99180b4838ec1269b3f4caee0201968" data-source-kind="9" data-glyph="barlineFinal" data-class="barline"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,15 @@
fixture=ten_measure_with_slurs solver=stub
glyph_count=51
path_count=51
fallback_rect_count=0
stroke_count=91
curve_count=3
provenance_count=145
layer_count=1
hard_constraint_count=90
xml_well_formed=true
view_box=[-3.065 -5.4266667 88.87701 13.253333]
class_counts:
barline=10
clef=1
notehead=40

View File

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="888.7701" height="132.5333" viewBox="0 0 88.877 13.2533">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(3.065 7.8267) scale(1 -1)">
<g data-layer="0">
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="01550dc9d118134419219044f188e28a" data-source-kind="6" data-kind="stroke"/>
<line x1="-1" y1="0" x2="83.4" y2="0" stroke="#000000" stroke-width="0.13" data-prov="5235156954d3cdda987a3da1af222a55" data-source-kind="3" data-kind="stroke"/>
<line x1="-1" y1="1" x2="83.4" y2="1" stroke="#000000" stroke-width="0.13" data-prov="10cbe3e182e937b8e3d90a03397b2e97" data-source-kind="3" data-kind="stroke"/>
<line x1="-1" y1="2" x2="83.4" y2="2" stroke="#000000" stroke-width="0.13" data-prov="94825cd467fe6fb512d271e66b59674a" data-source-kind="3" data-kind="stroke"/>
<line x1="-1" y1="3" x2="83.4" y2="3" stroke="#000000" stroke-width="0.13" data-prov="bc2cd26cec8e4650eedcf04fbc91320f" data-source-kind="3" data-kind="stroke"/>
<line x1="-1" y1="4" x2="83.4" y2="4" stroke="#000000" stroke-width="0.13" data-prov="c46b9445a4c5ff909f50b13f9025c4a3" data-source-kind="3" data-kind="stroke"/>
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="fe116eb75ff697f9f2451c10c05349c6" data-source-kind="2" data-kind="stroke"/>
<line x1="5.75" y1="-1" x2="5.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="dbd573fbc0c21f9b07a8537f658b048c" data-source-kind="0" data-kind="stroke"/>
<line x1="4.3" y1="-1" x2="6.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="07ffb45f25e59b0955bd4e248522b2ef" data-source-kind="1" data-kind="stroke"/>
<line x1="7.35" y1="-1" x2="7.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="276b6f72f2bcb818f84229b6833ce022" data-source-kind="0" data-kind="stroke"/>
<line x1="5.9" y1="-1" x2="7.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="3539af5cd23dc6ca259a35fef4aa45c2" data-source-kind="1" data-kind="stroke"/>
<line x1="8.95" y1="-1" x2="8.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="d1b0067ebb2b2b9115a77aaadc53bffe" data-source-kind="0" data-kind="stroke"/>
<line x1="7.5" y1="-1" x2="9.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="9b43d5d2a83ce1086364f6f3ac4b5ba2" data-source-kind="1" data-kind="stroke"/>
<line x1="10.55" y1="-1" x2="10.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="13303d184ca25b99835d71e21f3a96df" data-source-kind="0" data-kind="stroke"/>
<line x1="9.1" y1="-1" x2="10.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="d6c7a9458a06532e1c7507e71363c0dd" data-source-kind="1" data-kind="stroke"/>
<line x1="13.75" y1="-1" x2="13.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="aa33134cf72cebda65d1b2b427230541" data-source-kind="0" data-kind="stroke"/>
<line x1="12.3" y1="-1" x2="14.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="20fbe0162b0bc4dd93e2c3e3da73bb83" data-source-kind="1" data-kind="stroke"/>
<line x1="15.35" y1="-1" x2="15.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="01d06ae931cf05983e4b17744108297b" data-source-kind="0" data-kind="stroke"/>
<line x1="13.9" y1="-1" x2="15.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="cd9abf9ed66ab876f68b9d88ce1e5199" data-source-kind="1" data-kind="stroke"/>
<line x1="16.95" y1="-1" x2="16.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="47c9ce76466974ecfabe4a9cafb75c3c" data-source-kind="0" data-kind="stroke"/>
<line x1="15.5" y1="-1" x2="17.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="7ade41165c11102f29e87cb09eb3f88f" data-source-kind="1" data-kind="stroke"/>
<line x1="18.55" y1="-1" x2="18.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="e6d1c0a44d13be2700ad4381e1d518fa" data-source-kind="0" data-kind="stroke"/>
<line x1="17.1" y1="-1" x2="18.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="32c2aa89808756c46e8b7afcfb2eb057" data-source-kind="1" data-kind="stroke"/>
<line x1="21.75" y1="-1" x2="21.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="370a65aab516f6792e49a1ed28bd5cd9" data-source-kind="0" data-kind="stroke"/>
<line x1="20.3" y1="-1" x2="22.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="0a3ae3c155d29c21e266e663a8b1b214" data-source-kind="1" data-kind="stroke"/>
<line x1="23.35" y1="-1" x2="23.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="75b4bca159d5c28b10b2078bb457bb53" data-source-kind="0" data-kind="stroke"/>
<line x1="21.9" y1="-1" x2="23.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="c27e3e835224dc54cef9b36818099cd0" data-source-kind="1" data-kind="stroke"/>
<line x1="24.95" y1="-1" x2="24.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="c4531390972e55a43af2bd9f85e3c250" data-source-kind="0" data-kind="stroke"/>
<line x1="23.5" y1="-1" x2="25.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="4b43990bc25bf4afffda3e2a7d4dcab7" data-source-kind="1" data-kind="stroke"/>
<line x1="26.55" y1="-1" x2="26.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="888c6ef019fc4b0fc714672a4444c60c" data-source-kind="0" data-kind="stroke"/>
<line x1="25.1" y1="-1" x2="26.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="8e120598410629bb86883f318071a6b5" data-source-kind="1" data-kind="stroke"/>
<line x1="29.75" y1="-1" x2="29.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="6c70ad9006ed8fbb104d77eba3f2dbcb" data-source-kind="0" data-kind="stroke"/>
<line x1="28.3" y1="-1" x2="30.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="6d2724bb96160f831b4e987ce6b4349a" data-source-kind="1" data-kind="stroke"/>
<line x1="31.35" y1="-1" x2="31.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="debe82da2c34817a871d8a4cc5cc3fdd" data-source-kind="0" data-kind="stroke"/>
<line x1="29.9" y1="-1" x2="31.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="7a9f84c8590c7e8156fd929e35153cf8" data-source-kind="1" data-kind="stroke"/>
<line x1="32.95" y1="-1" x2="32.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="7b524c2e9e5657b68fd1a81337887c2e" data-source-kind="0" data-kind="stroke"/>
<line x1="31.5" y1="-1" x2="33.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="c4a9602f838e0ea74a9fe68da12baff2" data-source-kind="1" data-kind="stroke"/>
<line x1="34.55" y1="-1" x2="34.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="cb31fbbd4a3e3eb17ed06e65f40589e5" data-source-kind="0" data-kind="stroke"/>
<line x1="33.1" y1="-1" x2="34.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="76860486e62be8520184bb3e8c284d94" data-source-kind="1" data-kind="stroke"/>
<line x1="37.75" y1="-1" x2="37.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="bc1cc9bede7fc3c72827cfbf184cdb7e" data-source-kind="0" data-kind="stroke"/>
<line x1="36.3" y1="-1" x2="38.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="a3fd2ae47f432e61881cb1e44a7108e6" data-source-kind="1" data-kind="stroke"/>
<line x1="39.35" y1="-1" x2="39.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="95891bc2b58231a1618b5d422e6131b0" data-source-kind="0" data-kind="stroke"/>
<line x1="37.9" y1="-1" x2="39.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="a9e2ee2be83c0a4978db517bf182efe9" data-source-kind="1" data-kind="stroke"/>
<line x1="40.95" y1="-1" x2="40.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="ed21002861831422bdbb36e8f981d2a9" data-source-kind="0" data-kind="stroke"/>
<line x1="39.5" y1="-1" x2="41.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="2bac60a88ddddcf403dd6edfb67b5bb0" data-source-kind="1" data-kind="stroke"/>
<line x1="42.55" y1="-1" x2="42.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="e49748251746a6dbdf15aa8dd983e6e7" data-source-kind="0" data-kind="stroke"/>
<line x1="41.1" y1="-1" x2="42.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="a592bc951f4afa64726aa30ccdd6ddfa" data-source-kind="1" data-kind="stroke"/>
<line x1="45.75" y1="-1" x2="45.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="8d2938a01abfe451991829fbde6d2b94" data-source-kind="0" data-kind="stroke"/>
<line x1="44.3" y1="-1" x2="46.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="c73f1aad686621adde07e7fe7b160ad7" data-source-kind="1" data-kind="stroke"/>
<line x1="47.35" y1="-1" x2="47.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="88366a6edcce03db41d39d08b2616742" data-source-kind="0" data-kind="stroke"/>
<line x1="45.9" y1="-1" x2="47.6807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="4e052448582a8fdc672f38b02dc205e1" data-source-kind="1" data-kind="stroke"/>
<line x1="48.95" y1="-1" x2="48.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="77a10c7862f0ffce98d309bb71ce675f" data-source-kind="0" data-kind="stroke"/>
<line x1="47.5" y1="-1" x2="49.2807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="68c691f2336e5a322116641b9f2f7054" data-source-kind="1" data-kind="stroke"/>
<line x1="50.55" y1="-1" x2="50.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="cfa457e70519a1826f321a56cb3fd03b" data-source-kind="0" data-kind="stroke"/>
<line x1="49.1" y1="-1" x2="50.8807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="3c3f0d6b5ab00374d7628cd6f9876bc9" data-source-kind="1" data-kind="stroke"/>
<line x1="53.75" y1="-1" x2="53.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="273d04eeece605789035a29d80023c2c" data-source-kind="0" data-kind="stroke"/>
<line x1="52.3" y1="-1" x2="54.0807" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="9ec226573f8ea2320fc444c562163ddb" data-source-kind="1" data-kind="stroke"/>
<line x1="55.35" y1="-1" x2="55.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="a23b09fda2784b566bf1de48e89a2baa" data-source-kind="0" data-kind="stroke"/>
<line x1="53.9" y1="-1" x2="55.6806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="9e3345d5dddffbb9cf263a1708fc2589" data-source-kind="1" data-kind="stroke"/>
<line x1="56.95" y1="-1" x2="56.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="edf9ca4611cfc17faace6241cae3b73d" data-source-kind="0" data-kind="stroke"/>
<line x1="55.5" y1="-1" x2="57.2806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="ea06a5b83026202fbe3eed55ca3680c9" data-source-kind="1" data-kind="stroke"/>
<line x1="58.55" y1="-1" x2="58.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="f1e3c0367633266d5f188e5bd6d98ad4" data-source-kind="0" data-kind="stroke"/>
<line x1="57.1" y1="-1" x2="58.8806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="b8d324f0620ebb59f66346b61a4d214e" data-source-kind="1" data-kind="stroke"/>
<line x1="61.75" y1="-1" x2="61.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="2bf824905032462ee8c9988b780feabf" data-source-kind="0" data-kind="stroke"/>
<line x1="60.3" y1="-1" x2="62.0806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="579f0f80918268f1ba03890d14832b56" data-source-kind="1" data-kind="stroke"/>
<line x1="63.35" y1="-1" x2="63.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="08a65aebf8baaded74feb3ec7cc36d18" data-source-kind="0" data-kind="stroke"/>
<line x1="61.9" y1="-1" x2="63.6806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="f04f04c436808c53ab1c2b169d7f5a33" data-source-kind="1" data-kind="stroke"/>
<line x1="64.95" y1="-1" x2="64.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="d155779804ad660f4466745153872349" data-source-kind="0" data-kind="stroke"/>
<line x1="63.5" y1="-1" x2="65.2806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="3b9174fe7361dbfc454fbe17b9ec81b1" data-source-kind="1" data-kind="stroke"/>
<line x1="66.55" y1="-1" x2="66.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="af73177552d5fa95b746caf62d000623" data-source-kind="0" data-kind="stroke"/>
<line x1="65.1" y1="-1" x2="66.8806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="46e6eac9f03c1e09a15cb0bb07628570" data-source-kind="1" data-kind="stroke"/>
<line x1="69.75" y1="-1" x2="69.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="7c839e4fb5f2522c8fde3b48577eafae" data-source-kind="0" data-kind="stroke"/>
<line x1="68.3" y1="-1" x2="70.0806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="0e4ec9f37979b06c54f0e2d499df5bf1" data-source-kind="1" data-kind="stroke"/>
<line x1="71.35" y1="-1" x2="71.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="0c8e0b8170c827494e606d4541ce2c89" data-source-kind="0" data-kind="stroke"/>
<line x1="69.9" y1="-1" x2="71.6806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="61f1ed037baf82bf6594769a9205df07" data-source-kind="1" data-kind="stroke"/>
<line x1="72.95" y1="-1" x2="72.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="42480ffc14ad129d8dec456e7bad317c" data-source-kind="0" data-kind="stroke"/>
<line x1="71.5" y1="-1" x2="73.2806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="adc82c9de5d3ed333e335c32f7690a6b" data-source-kind="1" data-kind="stroke"/>
<line x1="74.55" y1="-1" x2="74.55" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="0f408610c63793003f04e3af5a03493f" data-source-kind="0" data-kind="stroke"/>
<line x1="73.1" y1="-1" x2="74.8806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="1dee0e17b4b9167cda8e51b45e2ab47c" data-source-kind="1" data-kind="stroke"/>
<line x1="76.15" y1="-1" x2="76.15" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="99d50b32a7d1eac1019c14132a834144" data-source-kind="0" data-kind="stroke"/>
<line x1="74.7" y1="-1" x2="76.4806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="c33f0e929fa3ea4230c07aca80f60c04" data-source-kind="1" data-kind="stroke"/>
<line x1="77.75" y1="-1" x2="77.75" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="572e96450f21b18771e510c6c41444dc" data-source-kind="0" data-kind="stroke"/>
<line x1="76.3" y1="-1" x2="78.0806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="67463b29bfb160641501f497f6134aa6" data-source-kind="1" data-kind="stroke"/>
<line x1="79.35" y1="-1" x2="79.35" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="68935c410dbe357c3f80b01c6e6d3cb1" data-source-kind="0" data-kind="stroke"/>
<line x1="77.9" y1="-1" x2="79.6806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="75f41055f9e23911ca51158dab6488df" data-source-kind="1" data-kind="stroke"/>
<line x1="80.95" y1="-1" x2="80.95" y2="2.5" stroke="#000000" stroke-width="0.12" data-prov="9e9ba65603d31963bdc6d55dfacc5eba" data-source-kind="0" data-kind="stroke"/>
<line x1="79.5" y1="-1" x2="81.2806" y2="-1" stroke="#000000" stroke-width="0.13" data-prov="66a37f88caecfe799ec34469d2302afd" data-source-kind="1" data-kind="stroke"/>
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="40a9a55985c8bf8b1eb66b6d8c8e7b7a" data-source-kind="12" data-kind="stroke"/>
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="10eb9773d25f0d7d64f92b36f88e5b38" data-source-kind="14" data-kind="stroke"/>
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="86a982398112115027c54222505dd143" data-source-kind="15" data-kind="stroke"/>
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="c183669ac91f35eb7a0040b48fde85ab" data-source-kind="25" data-kind="stroke"/>
<path d="M 5.2 4.7 C 6.4 5.7667 7.6 5.7667 8.8 4.7" fill="none" stroke="#000000" stroke-width="0.12" data-prov="cf7d96019ed88156f3cc33bf116fbae3" data-source-kind="11" data-kind="curve"/>
<path d="M 14.8 -0.7 C 16.5333 -3.3667 18.2667 -3.3667 20 -0.7" fill="none" stroke="#000000" stroke-width="0.12" data-prov="c366aecd1ef6e40cb1b3ffd2100eb3d8" data-source-kind="11" data-kind="curve"/>
<path d="M 24.4 4.7 C 25.6 5.7667 26.8 5.7667 28 4.7" fill="none" stroke="#000000" stroke-width="0.12" data-prov="b6c086f751dd21ffa2520010201f057f" data-source-kind="11" data-kind="curve"/>
<path d="M1.504 1.66C1.496 1.708 1.504 1.712 1.528 1.736C1.96 2.14 2.288 2.648 2.288 3.26C2.288 3.608 2.192 3.952 2.028 4.192C1.968 4.28 1.864 4.392 1.82 4.392C1.764 4.392 1.64 4.288 1.56 4.2C1.264 3.872 1.168 3.372 1.168 2.956C1.168 2.724 1.196 2.464 1.224 2.3C1.232 2.252 1.236 2.244 1.188 2.204C0.612 1.728 0 1.156 0 0.348C0 -0.348 0.476 -1.008 1.456 -1.008C1.548 -1.008 1.652 -1 1.732 -0.984C1.776 -0.976 1.784 -0.972 1.792 -1.02C1.84 -1.288 1.9 -1.636 1.9 -1.824C1.9 -2.416 1.5 -2.488 1.264 -2.488C1.048 -2.488 0.944 -2.424 0.944 -2.372C0.944 -2.344 0.98 -2.332 1.072 -2.304C1.196 -2.268 1.34 -2.16 1.34 -1.928C1.34 -1.708 1.2 -1.52 0.956 -1.52C0.688 -1.52 0.528 -1.732 0.528 -1.98C0.528 -2.24 0.684 -2.632 1.288 -2.632C1.556 -2.632 2.076 -2.512 2.076 -1.832C2.076 -1.604 2.004 -1.224 1.96 -0.976C1.952 -0.928 1.956 -0.932 2.012 -0.908C2.416 -0.748 2.684 -0.408 2.684 0.044C2.684 0.556 2.308 1.008 1.72 1.008C1.616 1.008 1.616 1.008 1.604 1.08ZM1.88 3.772C2.012 3.772 2.12 3.664 2.12 3.444C2.12 3 1.74 2.64 1.424 2.364C1.396 2.34 1.38 2.344 1.372 2.396C1.356 2.5 1.348 2.636 1.348 2.764C1.348 3.388 1.636 3.772 1.88 3.772ZM1.444 1.048C1.456 0.972 1.456 0.976 1.384 0.952C1.032 0.832 0.804 0.516 0.804 0.176C0.804 -0.184 0.992 -0.44 1.264 -0.532C1.296 -0.544 1.344 -0.556 1.372 -0.556C1.404 -0.556 1.42 -0.536 1.42 -0.512C1.42 -0.484 1.388 -0.472 1.36 -0.46C1.192 -0.388 1.072 -0.216 1.072 -0.032C1.072 0.196 1.228 0.368 1.472 0.436C1.536 0.452 1.544 0.448 1.552 0.404L1.752 -0.788C1.76 -0.832 1.756 -0.832 1.696 -0.844C1.632 -0.856 1.552 -0.864 1.472 -0.864C0.772 -0.864 0.32 -0.476 0.32 0.08C0.32 0.316 0.36 0.632 0.692 1.008C0.932 1.276 1.116 1.424 1.304 1.576C1.344 1.608 1.352 1.604 1.36 1.56ZM1.72 0.412C1.712 0.46 1.716 0.472 1.764 0.468C2.088 0.44 2.356 0.168 2.356 -0.184C2.356 -0.436 2.204 -0.64 1.98 -0.752C1.932 -0.776 1.924 -0.776 1.916 -0.728Z" transform="translate(0 1)" fill="#000000" data-prov="2bc09f9490decea3e13f24459f1b64f4" data-source-kind="4" data-glyph="gClef" data-class="clef"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(4.6 -1)" fill="#000000" data-prov="870b54f8730bed8251f4f891a3c4b233" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(6.2 -1)" fill="#000000" data-prov="5833135b23eaf581bccad90222f64e7e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(7.8 -1)" fill="#000000" data-prov="3c653d764b31b22273540a079363370c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(9.4 -1)" fill="#000000" data-prov="dfd195901678557bdc1f1351634d2b68" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(12.6 -1)" fill="#000000" data-prov="99d206152de1651afeef963c42d7984f" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(14.2 -1)" fill="#000000" data-prov="1340dc8c93d5925410da02d683dd5916" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(15.8 -1)" fill="#000000" data-prov="d9b7e874516d265e3c165a16887a22bd" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(17.4 -1)" fill="#000000" data-prov="12ef29aff1cc56481133aec42c0d1d35" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(20.6 -1)" fill="#000000" data-prov="6f2586548ccd7de6800f74edd852d03b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(22.2 -1)" fill="#000000" data-prov="6cd2fcd32ca7d1441b6534c025d7ae93" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(23.8 -1)" fill="#000000" data-prov="e71f03f7d3d50a64ea72ce4bcdffb03e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(25.4 -1)" fill="#000000" data-prov="0227904e88f6312904444c17ae540b57" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(28.6 -1)" fill="#000000" data-prov="6cce2821ccbb4f5769708cb9ca3c38a7" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(30.2 -1)" fill="#000000" data-prov="9952a70d5e0f9911d93466e83edc252e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(31.8 -1)" fill="#000000" data-prov="1ead59d59c0c7cec46071003e7741905" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(33.4 -1)" fill="#000000" data-prov="4b82f873de97ed4fa80748acbf368585" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(36.6 -1)" fill="#000000" data-prov="a26098a444bb635ce82ff700f48491ed" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(38.2 -1)" fill="#000000" data-prov="460c0831f9fd8de9b01a3a4bac641c8e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(39.8 -1)" fill="#000000" data-prov="50dc15968bfd55c7637d0dde7c2f7bb3" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(41.4 -1)" fill="#000000" data-prov="d095c952d8865af497a68b9fb543c15e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(44.6 -1)" fill="#000000" data-prov="1e4cb41d3c7556e314182c82b7790bef" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(46.2 -1)" fill="#000000" data-prov="9645397274cb84055aeba5639f8b06a5" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(47.8 -1)" fill="#000000" data-prov="9578182658c00300531491532d13f66a" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(49.4 -1)" fill="#000000" data-prov="0a4dcf79021a9ba0366091e34fab456c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(52.6 -1)" fill="#000000" data-prov="4b3905877c48341998d76f07cdb9eff5" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(54.2 -1)" fill="#000000" data-prov="4582d540c93386289e704713ab2deeeb" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(55.8 -1)" fill="#000000" data-prov="b6d88aa94cc5462fed11ef21a9b9d6df" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(57.4 -1)" fill="#000000" data-prov="88fff9d9a3f4097be33105eff4f0664a" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(60.6 -1)" fill="#000000" data-prov="1c0a15caddb0e912285270f67ff23a3b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(62.2 -1)" fill="#000000" data-prov="82e4c22515ed591ba37576873988586e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(63.8 -1)" fill="#000000" data-prov="6304bcb23912da87e33c77509f7985f4" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(65.4 -1)" fill="#000000" data-prov="99878edbfc3bbb1e8eca463cccd584ce" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(68.6 -1)" fill="#000000" data-prov="d10593c1a375a3b07eb107dae9e897b3" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(70.2 -1)" fill="#000000" data-prov="ba9722cd8f95336b0804751c84983573" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(71.8 -1)" fill="#000000" data-prov="103fdcd52932b1c8f5fab0036c2dd002" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(73.4 -1)" fill="#000000" data-prov="fcd49faa62e91333932dbac75206c859" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(75 -1)" fill="#000000" data-prov="cc13049cf24d89de78d5dcc3bed18d7f" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(76.6 -1)" fill="#000000" data-prov="88a15f00dc6e06aaf9f2532f05f6197e" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(78.2 -1)" fill="#000000" data-prov="cd16148313e9a4f391ab0cabfeb1ee4c" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.388 -0.5C0.744 -0.5 1.18 -0.172 1.18 0.168C1.18 0.372 1.02 0.5 0.792 0.5C0.352 0.5 0 0.176 0 -0.168C0 -0.376 0.172 -0.5 0.388 -0.5Z" transform="translate(79.8 -1)" fill="#000000" data-prov="df598a181d9acccffc346705fce5da9b" data-source-kind="1" data-glyph="noteheadBlack" data-class="notehead"/>
<path d="M0.144 0V4H0V0Z" transform="translate(3 0)" fill="#000000" data-prov="867b15e7cfaeccccfe301517166fe106" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(11 0)" fill="#000000" data-prov="2d8913235b36cc0b1c41dc5238cba014" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(19 0)" fill="#000000" data-prov="b263edd8a4514fa237d4e7942c04be8c" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(27 0)" fill="#000000" data-prov="abc1bd57e0116cfc47c4f84f0c22b2e6" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(35 0)" fill="#000000" data-prov="9f811a0578b546d35393e29bb06afe9b" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(43 0)" fill="#000000" data-prov="7c62f742d0a60d8a6177a714ef9241af" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(51 0)" fill="#000000" data-prov="0b1e63cbd07616159ee70000e3c76056" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(59 0)" fill="#000000" data-prov="f21f4e337050d50f36f7d905196db794" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0Z" transform="translate(67 0)" fill="#000000" data-prov="f4a3968aa56472207e4e921c44dfa1d7" data-source-kind="9" data-glyph="barlineSingle" data-class="barline"/>
<path d="M0.144 0V4H0V0ZM0.912 4H0.412V0H0.912Z" transform="translate(82.9 0)" fill="#000000" data-prov="d99180b4838ec1269b3f4caee0201968" data-source-kind="9" data-glyph="barlineFinal" data-class="barline"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -3,6 +3,7 @@ glyph_count=11
path_count=11 path_count=11
fallback_rect_count=0 fallback_rect_count=0
stroke_count=38 stroke_count=38
curve_count=0
provenance_count=49 provenance_count=49
layer_count=1 layer_count=1
hard_constraint_count=15 hard_constraint_count=15

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="152.9842" height="330.7478" viewBox="0 0 15.2984 33.0748"> <svg xmlns="http://www.w3.org/2000/svg" width="152.9842" height="330.7478" viewBox="0 0 15.2984 33.0748">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(-5.5 -5.5006) scale(1 -1)"> <g transform="translate(-5.5 -5.5006) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="8.6599" y1="-12.8926" x2="8.6599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="5fc3f9fc71005c8f91cef3c82cf457d7" data-source-kind="6" data-kind="stroke"/> <line x1="8.6599" y1="-12.8926" x2="8.6599" y2="-12.8926" stroke="#000000" stroke-width="0" data-prov="5fc3f9fc71005c8f91cef3c82cf457d7" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -3,6 +3,7 @@ glyph_count=11
path_count=11 path_count=11
fallback_rect_count=0 fallback_rect_count=0
stroke_count=38 stroke_count=38
curve_count=0
provenance_count=49 provenance_count=49
layer_count=1 layer_count=1
hard_constraint_count=15 hard_constraint_count=15

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="393.3" height="110.24" viewBox="0 0 39.33 11.024"> <svg xmlns="http://www.w3.org/2000/svg" width="393.3" height="110.24" viewBox="0 0 39.33 11.024">
<!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph and stroke carries a data-prov trace to its score-graph source --> <!-- epiphany-render-svg: glyphs are genuine Bravura SMuFL outlines inlined as paths; geometry is the resolved layout verbatim, no engraving performed here; every glyph, stroke, and curve carries a data-prov trace to its score-graph source -->
<g transform="translate(3.065 7.392) scale(1 -1)"> <g transform="translate(3.065 7.392) scale(1 -1)">
<g data-layer="0"> <g data-layer="0">
<line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="5fc3f9fc71005c8f91cef3c82cf457d7" data-source-kind="6" data-kind="stroke"/> <line x1="0" y1="0" x2="0" y2="0" stroke="#000000" stroke-width="0" data-prov="5fc3f9fc71005c8f91cef3c82cf457d7" data-source-kind="6" data-kind="stroke"/>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -11,17 +11,19 @@
use epiphany_core::{ use epiphany_core::{
AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal, AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal,
CrossCuttingRegistry, Event, EventArena, EventDuration, EventPosition, IdentifiedPitch, CrossCuttingRegistry, CurvatureOverride, CurveDirection, Event, EventArena, EventDuration,
IdentityContext, Marker, Measure, MeasurePosition, MetricTimeModel, MusicalDuration, EventPosition, IdentifiedPitch, IdentityContext, Marker, Measure, MeasurePosition,
MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition,
RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure, ScalePosition, Score, Spanner, Staff, RationalTime, RegionContent, RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure,
StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, ScalePosition, Score, Slur, SlurKind, SpaceUnit, SpanStyle, Spanner, Staff, StaffBasedContent,
TieClass, TimeAnchor, TimeExtent, TuningReference, Voice, Volta, WallClockTime, StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, TieClass,
TimeAnchor, TimeExtent, TuningReference, Voice, Volta, WallClockTime,
}; };
use epiphany_core::{ use epiphany_core::{
ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId,
RepeatStructureId, ReplicaId, SlurId, SpannerId, StaffId, StaffInstanceId, TieId, VoiceId, RepeatStructureId, ReplicaId, SlurId, SpannerId, StaffId, StaffInstanceId, TieId, VoiceId,
}; };
use epiphany_determinism::CanonicalF64;
use epiphany_determinism::fuzz::SplitMix64; use epiphany_determinism::fuzz::SplitMix64;
@ -257,6 +259,48 @@ pub fn ten_measure_with_repeats(seed: u64) -> Score {
score score
} }
/// [`ten_measure_single_staff`] plus three slurs — the slur-rendering
/// acceptance fixture (schema-major-2 E2). All endpoints are events on the one
/// staff, so each resolves to a note column: a default (Legato, auto direction
/// = above) slur over the first four notes, an authored below slur with an
/// explicit apex height over the middle four, and an editorial slur over a
/// later pair. Invariant-clean.
pub fn ten_measure_with_slurs(seed: u64) -> Score {
let mut score = ten_measure_single_staff(seed);
let events: Vec<EventId> = score.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone();
score.cross_cutting.slurs.push(Slur {
id: score.identity.mint::<SlurId>(),
start_event: events[0],
end_event: events[3],
kind: SlurKind::Legato,
curvature_override: None,
style: SpanStyle::default(),
});
score.cross_cutting.slurs.push(Slur {
id: score.identity.mint::<SlurId>(),
start_event: events[5],
end_event: events[8],
kind: SlurKind::Phrase,
curvature_override: Some(CurvatureOverride {
direction: Some(CurveDirection::Below),
height: Some(SpaceUnit(CanonicalF64::new(2.0).expect("2.0 is finite"))),
}),
style: SpanStyle::default(),
});
score.cross_cutting.slurs.push(Slur {
id: score.identity.mint::<SlurId>(),
start_event: events[10],
end_event: events[12],
kind: SlurKind::Editorial,
curvature_override: None,
style: SpanStyle::default(),
});
score
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -293,6 +337,24 @@ mod tests {
); );
} }
#[test]
fn slur_fixture_is_invariant_clean_and_carries_three_slurs() {
let s = ten_measure_with_slurs(1);
let v = check_invariants(&s);
assert!(v.is_empty(), "slur fixture has violations: {v:?}");
assert_eq!(s.cross_cutting.slurs.len(), 3);
// One slur authors a below-direction curvature override; the rest use
// the engraver's default (above).
assert_eq!(
s.cross_cutting
.slurs
.iter()
.filter(|slur| slur.curvature_override.is_some())
.count(),
1
);
}
/// A measure that references a time signature lists it (and its start /// A measure that references a time signature lists it (and its start
/// anchor's target) among its invalidation dependencies, so a time-signature /// anchor's target) among its invalidation dependencies, so a time-signature
/// display change with an unchanged id invalidates the measure and its /// display change with an unchanged id invalidates the measure and its

View File

@ -346,6 +346,22 @@ pub fn gen_stroke(rng: &mut Rng) -> Stroke {
} }
} }
/// A cubic-bézier curve primitive with generated provenance and geometry.
pub fn gen_curve(rng: &mut Rng) -> Curve {
Curve {
provenance: gen_provenance(rng),
p0: gen_point(rng),
p1: gen_point(rng),
p2: gen_point(rng),
p3: gen_point(rng),
thickness: gen_staff_space(rng),
layer: rng.range(0, 8) as i32 - 4,
style: GlyphStyle {
rgba: rng.next_u64() as u32,
},
}
}
/// A constrained layout IR whose catalog hash covers exactly its glyph metrics /// A constrained layout IR whose catalog hash covers exactly its glyph metrics
/// (so the real stub solver accepts it as well-formed), with an **internally /// (so the real stub solver accepts it as well-formed), with an **internally
/// consistent** vertical band: every glyph names the band, and the band's /// consistent** vertical band: every glyph names the band, and the band's
@ -389,6 +405,7 @@ pub fn gen_constrained_layout_ir(rng: &mut Rng) -> ConstrainedLayoutIR {
strokes: (0..rng.range_usize(0, 3)) strokes: (0..rng.range_usize(0, 3))
.map(|_| gen_stroke(rng)) .map(|_| gen_stroke(rng))
.collect(), .collect(),
curves: (0..rng.range_usize(0, 3)).map(|_| gen_curve(rng)).collect(),
vertical_bands: vec![band], vertical_bands: vec![band],
constraints: vec![], constraints: vec![],
break_origins: vec![], break_origins: vec![],
@ -489,13 +506,15 @@ pub fn gen_round_trip_report(rng: &mut Rng) -> RoundTripReport {
.iter() .iter()
.map(|primitive| primitive.provenance.source) .map(|primitive| primitive.provenance.source)
.chain(render.strokes.iter().map(|stroke| stroke.provenance.source)) .chain(render.strokes.iter().map(|stroke| stroke.provenance.source))
.chain(render.curves.iter().map(|curve| curve.provenance.source))
.collect(); .collect();
let total = render.primitives.len() + render.strokes.len(); let total = render.primitives.len() + render.strokes.len() + render.curves.len();
RoundTripReport { RoundTripReport {
status: SolveStatus::Solved, status: SolveStatus::Solved,
logical_objects: total, logical_objects: total,
glyphs: render.primitives.len(), glyphs: render.primitives.len(),
render_strokes: render.strokes.len(), render_strokes: render.strokes.len(),
render_curves: render.curves.len(),
render_primitives: total, render_primitives: total,
recovered_sources, recovered_sources,
} }