From 81b7f42f0ab91941c66b06d161fedb2ce8a2531e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 15:33:00 -0400 Subject: [PATCH] =?UTF-8?q?Schema=20major=202=20Phase=20E2:=20slur=20curve?= =?UTF-8?q?s=20+=20cubic-b=C3=A9zier=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- crates/epiphany-editor-core/src/lib.rs | 53 ++- crates/epiphany-editor-gui/src/main.rs | 12 + crates/epiphany-engrave/DECISIONS.md | 27 ++ crates/epiphany-engrave/src/casting.rs | 100 ++++- crates/epiphany-engrave/src/lib.rs | 63 ++- crates/epiphany-engrave/src/quality.rs | 33 +- crates/epiphany-layout-ir/DECISIONS.md | 55 +++ crates/epiphany-layout-ir/src/constrained.rs | 399 +++++++++++++++++- crates/epiphany-layout-ir/src/hittest.rs | 195 ++++++++- crates/epiphany-layout-ir/src/lib.rs | 9 +- crates/epiphany-layout-ir/src/logical.rs | 86 +++- crates/epiphany-layout-ir/src/render.rs | 5 +- crates/epiphany-layout-ir/src/resolved.rs | 32 +- crates/epiphany-layout-ir/src/roundtrip.rs | 40 +- crates/epiphany-layout-ir/src/solver.rs | 12 +- crates/epiphany-render-svg/DECISIONS.md | 12 + crates/epiphany-render-svg/src/svg.rs | 73 +++- .../epiphany-render-svg/tests/acceptance.rs | 13 +- ..._measure_single_staff.engrave.snapshot.txt | 1 + .../ten_measure_single_staff.engrave.svg | 2 +- ...ten_measure_single_staff.stub.snapshot.txt | 1 + .../golden/ten_measure_single_staff.stub.svg | 2 +- ..._measure_with_repeats.engrave.snapshot.txt | 1 + .../ten_measure_with_repeats.engrave.svg | 2 +- ...ten_measure_with_repeats.stub.snapshot.txt | 1 + .../golden/ten_measure_with_repeats.stub.svg | 2 +- ...en_measure_with_slurs.engrave.snapshot.txt | 15 + .../golden/ten_measure_with_slurs.engrave.svg | 158 +++++++ .../ten_measure_with_slurs.stub.snapshot.txt | 15 + .../golden/ten_measure_with_slurs.stub.svg | 153 +++++++ .../valid_score_rich.engrave.snapshot.txt | 1 + .../tests/golden/valid_score_rich.engrave.svg | 2 +- .../golden/valid_score_rich.stub.snapshot.txt | 1 + .../tests/golden/valid_score_rich.stub.svg | 2 +- crates/epiphany-testkit/src/fixtures.rs | 74 +++- crates/epiphany-testkit/src/layout_stub.rs | 21 +- 36 files changed, 1580 insertions(+), 93 deletions(-) create mode 100644 crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt create mode 100644 crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg create mode 100644 crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.snapshot.txt create mode 100644 crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 49258c3..21a5349 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -2646,7 +2646,7 @@ mod tests { (b.left.0 + b.right.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"); session.click(click).expect("the click selects a glyph") @@ -2664,6 +2664,57 @@ mod tests { 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 /// room after it (nothing follows in that voice), so an insert-after applies. fn last_event_pitch(session: &EditorSession) -> PitchId { diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index 4b825b1..c3143d9 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -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(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)), + ) + } } } diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index bbe7327..e723974 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -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` (unbounded page so x-disjointness compares one line; verified to fail 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. diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index 367ef47..e46d390 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -80,7 +80,7 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{StaffId, TypedObjectId}; use epiphany_layout_ir::{ continuation_instance_key, is_barline_glyph, is_rigid_width_stroke, synthesized_layout_id, - BreakClass, BreakKind, ConstrainedLayoutIR, DecisionSource, EngravingDecision, + BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource, EngravingDecision, EngravingDecisionKind, EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, @@ -179,6 +179,10 @@ pub(crate) struct CastLayout { /// system; a system-spanning stroke replaced by its first segment), then /// the synthesized continuation segments. pub strokes: Vec, + /// 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, /// The populated page tree (empty when the input declares no regions). pub pages: Vec, /// Break decisions this pass made (chosen breaks in reading order, then @@ -342,6 +346,7 @@ pub(crate) fn cast_off( input: &ConstrainedLayoutIR, spaced_glyphs: &[ResolvedGlyph], spaced_strokes: &[Stroke], + spaced_curves: &[Curve], geometry: &PageGeometry, ) -> CastLayout { // ---- Slot table (spaced coordinates) -------------------------------- @@ -542,6 +547,21 @@ pub(crate) fn cast_off( ) }) .collect(); + // Curves ride one system whole (Minimal boundary: no de Casteljau split) — + // the system whose clip interval contains the start control point, found + // via the same nearest-region logic strokes use. + let curve_systems: Vec> = spaced_curves + .iter() + .map(|curve| { + curve_system( + curve, + &system_of_slot, + ®ion_spans, + ®ion_systems, + &clips, + ) + }) + .collect(); // ---- System extents ---------------------------------------------------- let mut extents: Vec = 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 = extents.into_iter().map(Extent::normalized).collect(); // ---- Vertical stacking and page assignment ---------------------------- @@ -731,6 +766,32 @@ pub(crate) fn cast_off( } strokes.extend(continuations); + // Curves: each translated whole by its start system's placement (or left + // in the spaced frame if no region claimed it — same fallback as a + // Rigid(None) stroke). A curve whose span crosses an internal system break + // is a documented Minimal boundary: it is drawn whole in its start system, + // so its end control point lands at (spaced end x + start-system delta) — + // visually detached from its end note, which cast to a later system. The + // curve is kept regardless (dropping it would break the round-trip source + // surjection: the slur's source must be recovered from a resolved + // primitive); an honest split needs de Casteljau subdivision, deferred to + // a later tier. + let curves: Vec = 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 --------------------------------------------- let resolved_systems: Vec = systems .iter() @@ -787,6 +848,7 @@ pub(crate) fn cast_off( CastLayout { glyphs, strokes, + curves, pages, decisions, 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, + region_spans: &[Option<(f32, f32)>], + region_systems: &[Vec], + clips: &[(f32, f32)], +) -> Option { + 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). fn interval_distance(lo: f32, hi: f32, clip: (f32, f32)) -> f32 { if hi < clip.0 { diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 5520fa6..f9ee73e 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -79,7 +79,7 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_layout_ir::{ 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, ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState, 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` /// 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 -/// 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 /// baked geometry differs from version `3`'s invisible traced anchors, and a /// same-slot companion glyph — a time-signature digit, key-signature /// accidental, or spelling accidental — now rides its slot's rigid delta /// instead of drifting by interpolation; scores without such companions are -/// unchanged). -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(4); +/// unchanged), and to `5` when slur curves landed (a slur-bearing score gains +/// a drawn cubic-bézier curve where version `4` had only a traced anchor; the +/// resolved output now carries a third primitive kind, so its canonical bytes +/// differ; slur-free scores draw the same ink as before). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(5); impl Engraver { /// 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 // not leak geometry into the diagnostic layout (which reaches // canonical_bytes / the renderer). - let (spaced_glyphs, spaced_strokes): (Vec, Vec) = if structural_valid - { + let (spaced_glyphs, spaced_strokes, spaced_curves): ( + Vec, + Vec, + Vec, + ) = if structural_valid { let remap = HorizontalRemap::build(input); - (remap.glyphs(input), remap.strokes(input)) + ( + remap.glyphs(input), + remap.strokes(input), + remap.curves(input), + ) } else { - (Vec::new(), Vec::new()) + (Vec::new(), Vec::new(), Vec::new()) }; // Casting-off: break the spaced line into systems, stack them, assign // pages, and bake every position into the single world frame. Pure @@ -191,6 +201,7 @@ impl Engraver { input, &spaced_glyphs, &spaced_strokes, + &spaced_curves, &self.geometry, )) } else { @@ -287,16 +298,23 @@ impl Engraver { // glyph/stroke positions baked, the engraver's break decisions appended // to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including // any the solver itself made"). - let (glyphs, strokes, pages, engraving_decisions) = match cast { + let (glyphs, strokes, curves, pages, engraving_decisions) = match cast { Some(cast) => { let mut decisions = input.engraving_decisions.clone(); decisions.extend(cast.decisions); - (cast.glyphs, cast.strokes, cast.pages, decisions) + ( + cast.glyphs, + cast.strokes, + cast.curves, + cast.pages, + decisions, + ) } None => ( Vec::new(), Vec::new(), Vec::new(), + Vec::new(), input.engraving_decisions.clone(), ), }; @@ -309,6 +327,7 @@ impl Engraver { pages, glyphs, strokes, + curves, engraving_decisions, catalog: input.catalog.clone(), }, @@ -459,6 +478,30 @@ impl HorizontalRemap { }) .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 { + 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. diff --git a/crates/epiphany-engrave/src/quality.rs b/crates/epiphany-engrave/src/quality.rs index 184ed8f..a29f9bd 100644 --- a/crates/epiphany-engrave/src/quality.rs +++ b/crates/epiphany-engrave/src/quality.rs @@ -26,12 +26,14 @@ //! 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 //! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture). -//! * **`slur_shape_penalty` / `beam_slope_penalty`** — **vacuous 0.0**: the -//! pipeline draws no slur or beam geometry (slurs/beams exist logically, -//! not as curves/segments), so the contributing-unit sets are empty. The -//! catalog pins vacuous-0.0 deliberately and owns the honesty edge (its -//! "notated-but-unrendered" open question): rendering completeness is -//! governed by constraint families and visual acceptance, not these axes. +//! * **`slur_shape_penalty`** — **0.0 by construction** (E2): slurs now draw as +//! cubic-bézier curves, but the Minimal tier emits the *reference* arc from +//! the span and the authored/default curvature, so a drawn slur has zero +//! deviation from its ideal (an authored `curvature_override` is honored, not +//! penalized). A real penalty awaits a Standard-tier solver that compromises +//! 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 //! preferred heights: the constrained input's `InterStaffGap` bands //! (adjacent staff bands' resolved ink extents; the constrained stage's @@ -457,12 +459,15 @@ pub(crate) fn measure( anchors::COLLISION_R_WORST, ), spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST), - // No drawn slur geometry exists in this pipeline (slurs are logical - // objects, not curves): the contributing-unit set is empty, so the - // axis is exactly 0.0 per the catalog's vacuous-geometry rule. The - // catalog's "notated-but-unrendered" open question owns the honesty - // edge; the definition is pinned so the first slur-drawing release is - // measured from day one. + // Slurs now DRAW (E2: a cubic-bézier `Curve` per slur), but their shape + // is measured as ideal by construction: the Minimal tier emits the + // reference arc itself — a symmetric cubic from the span and the + // authored (or default) curvature — so a drawn slur has zero deviation + // from its ideal, and an authored `curvature_override` is honored, not + // 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), // Same vacuous rule: no drawn beam segments exist in this pipeline. beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST), @@ -579,7 +584,9 @@ mod tests { let vector = &report.metric_vector; assert_eq!(vector.collision_penalty.0, 0.0); 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.page_fill_efficiency.0, 0.0, "vacuous: single page"); assert!( diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 2b4558a..8affd71 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -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 measure-boundary columns from this crate's name vocabulary instead of a 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. diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index baec42e..c629ad9 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -27,7 +27,8 @@ use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, Overri use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference}; use crate::logical::{ apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, - RepeatContent, RepeatPlacement, ScoreVersion, StaffContent, + RepeatContent, RepeatPlacement, ScoreVersion, SlurContent, SlurDirection, SlurEndpoint, + StaffContent, }; use crate::provenance::{ 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 vertical bands and engraving decisions that the solver consumes alongside /// them (Chapter 7 §"Constraints"). @@ -105,6 +138,8 @@ pub struct ConstrainedLayoutIR { pub glyphs: Vec, /// Non-glyph line primitives (staff lines, stems, barlines, …). pub strokes: Vec, + /// Cubic-bézier curve primitives (slurs, …). + pub curves: Vec, pub vertical_bands: Vec, pub constraints: Vec, /// The user-override attributions behind the projected break constraints in @@ -309,6 +344,8 @@ pub enum ConstrainedValidationError { InvalidConstraintRegion(GlyphObjectId), /// A stroke has a non-finite endpoint or a non-finite/negative thickness. 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 @@ -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(()) } } @@ -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_DROP: f32 = 1.3; // ending-digit baseline, below the bracket line 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 /// spaces. The constrained stage performs no casting-off, so a region imposes @@ -650,6 +708,7 @@ pub fn try_to_constrained( ) -> Result { let mut glyphs = Vec::new(); let mut strokes = Vec::new(); + let mut curves = Vec::new(); let mut diagnostics = Vec::new(); let mut vertical_bands = Vec::new(); let mut horizontal_slots = Vec::new(); @@ -1015,6 +1074,7 @@ pub fn try_to_constrained( let mut emit = Emit { glyphs: &mut glyphs, strokes: &mut strokes, + curves: &mut curves, diagnostics: &mut diagnostics, column_members: BTreeMap::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 - // structure (ties, slurs, beams, tuplets, spanners, markers, …) - // have no Minimal-tier glyph; a zero-extent traced anchor keeps - // them. + // structure (ties, beams, tuplets, spanners, markers, …) have no + // Minimal-tier glyph; a zero-extent traced anchor keeps them. _ => emit.stroke(anchor(provenance, Point::new(default_x, yo))), } } @@ -1710,6 +1790,7 @@ pub fn try_to_constrained( horizontal_slots, glyphs, strokes, + curves, vertical_bands, constraints, break_origins, @@ -1800,10 +1881,11 @@ struct ColumnInfo { } /// 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> { glyphs: &'a mut Vec, strokes: &'a mut Vec, + curves: &'a mut Vec, diagnostics: &'a mut Vec, column_members: BTreeMap>, region_glyphs: Vec, @@ -1859,6 +1941,10 @@ impl Emit<'_> { self.strokes.push(stroke); } + fn curve(&mut self, curve: Curve) { + self.curves.push(curve); + } + fn diag(&mut self, source: TypedObjectId, kind: LayoutDiagnosticKind) { 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, +) -> Option { + 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, +) -> Option { + 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 /// 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 @@ -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 { + score.canvas.regions[0].staff_instances()[0].voices[0] + .events + .clone() + } + + fn slur(id: SlurId, start: EventId, end: EventId, over: Option) -> 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()); + } } diff --git a/crates/epiphany-layout-ir/src/hittest.rs b/crates/epiphany-layout-ir/src/hittest.rs index e9de4e9..463857d 100644 --- a/crates/epiphany-layout-ir/src/hittest.rs +++ b/crates/epiphany-layout-ir/src/hittest.rs @@ -25,11 +25,13 @@ use crate::render::{RenderIR, RenderPrimitive}; use crate::spatial::{BoundingBox, Point, Transform2D}; /// 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)] pub enum PrimitiveRef { Glyph(usize), Stroke(usize), + Curve(usize), } impl PrimitiveRef { @@ -53,6 +55,43 @@ pub enum HitShape { to: Point, 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 { + (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 { @@ -67,6 +106,15 @@ impl HitShape { 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, half_width, } => 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.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 /// later, i.e. on top. 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 { 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) } } -/// The hit-test map over a [`RenderIR`]: one [`HitRegion`] per primitive (glyph -/// and stroke). The public [`Self::regions`] vector is stored in construction -/// order (glyph regions first, then stroke regions), not z-order; callers that -/// need ordered selection results should use [`Self::hit`] or [`Self::within`]. +/// The hit-test map over a [`RenderIR`]: one [`HitRegion`] per primitive (glyph, +/// stroke, and curve). The public [`Self::regions`] vector is stored in +/// construction order (glyph regions, then stroke regions, then curve +/// regions), not z-order; callers that need ordered selection results should +/// use [`Self::hit`] or [`Self::within`]. #[derive(Clone, PartialEq, Debug)] pub struct HitTestMap { pub regions: Vec, @@ -190,15 +271,17 @@ impl HitTestMap { } impl RenderIR { - /// Builds the [`HitTestMap`]: one [`HitRegion`] per glyph primitive and per - /// stroke. Regions are stored in construction order (glyphs, then strokes); - /// each region's [`HitRegion::layer`] and primitive reference carry the true - /// paint order consumed by [`HitTestMap::hit`] and [`HitTestMap::within`]. + /// Builds the [`HitTestMap`]: one [`HitRegion`] per glyph primitive, per + /// stroke, and per curve. Regions are stored in construction order (glyphs, + /// then strokes, then curves); each region's [`HitRegion::layer`] and + /// 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 /// primitive's preserved [`crate::Provenance`]; its shape is computed in world /// coordinates. 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() { regions.push(HitRegion { primitive: PrimitiveRef::Glyph(i), @@ -223,6 +306,22 @@ impl RenderIR { 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 } } } @@ -358,7 +457,7 @@ fn segments_cross(p1: Point, p2: Point, p3: Point, p4: Point) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::constrained::to_constrained; + use crate::constrained::{to_constrained, Curve}; use crate::logical::to_logical; use crate::provenance::Provenance; 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)); } + #[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] fn a_glyph_world_box_is_its_local_box_placed_by_position_and_transform() { // No transform: the local box just shifts by the position. @@ -442,6 +598,7 @@ mod tests { let HitShape::Box(b) = RenderIR { primitives: vec![p.clone()], strokes: vec![], + curves: vec![], } .hit_test_map() .regions[0] @@ -460,6 +617,7 @@ mod tests { let HitShape::Box(b) = RenderIR { primitives: vec![t], strokes: vec![], + curves: vec![], } .hit_test_map() .regions[0] @@ -477,7 +635,7 @@ mod tests { // One region per glyph and per stroke, none dropped or invented. assert_eq!( map.regions.len(), - render.primitives.len() + render.strokes.len() + render.primitives.len() + render.strokes.len() + render.curves.len() ); assert!(!map.regions.is_empty()); @@ -501,6 +659,14 @@ mod tests { 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.layout_object, stable_id); @@ -558,6 +724,7 @@ mod tests { 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)], + curves: vec![], }; let map = render.hit_test_map(); 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)], + curves: vec![], }; let map = render.hit_test_map(); // A rubber-band around the first glyph and the stroke, but not the far glyph. @@ -608,6 +776,7 @@ mod tests { let render = RenderIR { primitives: vec![], strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(10.0, 10.0), 0)], + curves: vec![], }; let map = render.hit_test_map(); // AABB-overlapping but body-missing rect near (0, 10): rejected. diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index ebd6138..cd1fcea 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -95,7 +95,7 @@ pub use cache::{ pub use constrained::{ active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakClass, BreakKind, BreakOrigin, ConstrainedLayoutIR, ConstrainedLayoutRegion, - ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, GlyphObject, + ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, Curve, GlyphObject, GlyphObjectId, GlyphStyle, LayoutConstraint, LayoutTransformError, SpringSlot, Stroke, }; pub use engrave_theory::{ @@ -120,9 +120,10 @@ pub use logical::{ KeySignatureLayout, LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, MarkerLayout, MeasureContent, MultimeasureRestLayout, NoteContent, NoteLayout, NotePitch, PlacedClef, PlacedComponent, PlacedKeySignature, RepeatContent, RepeatPlacement, - RestContent, RestLayout, ScoreVersion, SlurLayout, SpannerLayout, StaffContent, StaffLayout, - TextLayout, TieLayout, TimeSignatureContent, TimeSignatureDisplayLayout, TrajectoryLayout, - TupletDisplayLayout, VerticalExtent, VoltaContent, + RestContent, RestLayout, ScoreVersion, SlurContent, SlurDirection, SlurEndpoint, SlurLayout, + SpannerLayout, StaffContent, StaffLayout, TextLayout, TieLayout, TimeSignatureContent, + TimeSignatureDisplayLayout, TrajectoryLayout, TupletDisplayLayout, VerticalExtent, + VoltaContent, }; pub use provenance::{ continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id, diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index 573c750..b0f6091 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -20,8 +20,8 @@ use epiphany_core::{ AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef, CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition, MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge, - RegionId, RegionTimeModel, Score, StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay, - TupletId, TupletRatio, TypedObjectId, WallClockTime, + RegionId, RegionTimeModel, Score, SpaceUnit, StaffId, StaffPosition, TimeAnchor, + TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId, WallClockTime, }; use epiphany_determinism::{DomainTag, Preimage}; @@ -61,6 +61,9 @@ pub enum LayoutContent { /// boundaries land, and its volta brackets — resolved to layout placements /// at projection time (the constrained pass has no score access). 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, @@ -201,6 +204,43 @@ pub enum RepeatPlacement { 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, + /// Authored line thickness (`style.thickness`); `None` = the engraver's + /// default. + pub thickness: Option, +} + +/// 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 /// carries its [`Provenance`], the staff it belongs to (used to route it to 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) { continue; } - // A repeat structure carries its resolved engraving content (barline - // verdict + placements). Every other cross-cutting object is - // structural in this tier. + // A repeat structure or slur carries its resolved engraving content + // (barline placements / endpoint onsets). Every other cross-cutting + // object is structural in this tier. let content = match src { TypedObjectId::RepeatStructure(id) => score .cross_cutting @@ -655,6 +695,13 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { .find(|rp| rp.id == id) .map(|rp| repeat_content(score, rp)) .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, }; 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 /// [`resolve_time_anchor`], failure is **honest** (`Unresolved` draws no ink) /// rather than falling back to the origin — a repeat sign at a false position diff --git a/crates/epiphany-layout-ir/src/render.rs b/crates/epiphany-layout-ir/src/render.rs index 1d8d178..2ad7fa0 100644 --- a/crates/epiphany-layout-ir/src/render.rs +++ b/crates/epiphany-layout-ir/src/render.rs @@ -11,7 +11,7 @@ use crate::provenance::Provenance; use crate::resolved::ResolvedLayoutIR; 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 /// 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 /// the glyph primitives so the round-trip recovers their sources too. pub strokes: Vec, + /// Cubic-bézier curve primitives (slurs, …), traced like the strokes. + pub curves: Vec, } /// The render target (Chapter 7 §"RenderIR": `RenderConfiguration.target`). @@ -143,5 +145,6 @@ pub fn to_render(resolved: &ResolvedLayoutIR) -> RenderIR { }) .collect(), strokes: resolved.strokes.clone(), + curves: resolved.curves.clone(), } } diff --git a/crates/epiphany-layout-ir/src/resolved.rs b/crates/epiphany-layout-ir/src/resolved.rs index 417c684..e07b563 100644 --- a/crates/epiphany-layout-ir/src/resolved.rs +++ b/crates/epiphany-layout-ir/src/resolved.rs @@ -24,7 +24,7 @@ use epiphany_core::{MeasureId, StaffId, TypedObjectId}; 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::glyph::{GlyphCatalogIdentity, GlyphReference}; use crate::logical::ScoreVersion; @@ -90,6 +90,9 @@ pub struct ResolvedLayoutIR { /// Resolved non-glyph line primitives (staff lines, stems, barlines, …), /// positioned by the solver alongside the glyphs. pub strokes: Vec, + /// Resolved cubic-bézier curve primitives (slurs, …), positioned by the + /// solver alongside the glyphs and strokes. + pub curves: Vec, pub engraving_decisions: Vec, /// The catalog identity under which this layout was produced — required for /// 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.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()); for decision in &self.engraving_decisions { encode_decision(out, decision); @@ -377,6 +392,7 @@ mod tests { pages: vec![], glyphs, strokes: vec![], + curves: vec![], engraving_decisions: decisions, catalog: GlyphCatalogIdentity::default(), } @@ -404,16 +420,16 @@ mod tests { // Schema major 1 unifies the resolved-layout length/count prefixes to // u32 (Binary Format companion §"Schema Major 1"). This locks the byte // shape so a revert to the old u64 prefixes fails: an empty layout - // encodes its four counts — pages, glyphs, strokes, engraving_decisions - // — as u32 zeros (16 bytes) right after the 32-byte ScoreVersion source, - // then the catalog. Under u64 that region would be 32 bytes, shifting the - // catalog and lengthening the output by 16. + // encodes its five counts — pages, glyphs, strokes, curves, + // engraving_decisions — as u32 zeros (20 bytes) right after the 32-byte + // ScoreVersion source, then the catalog. Under u64 that region would be + // 40 bytes, shifting the catalog and lengthening the output by 20. let bytes = ir(vec![], vec![]).canonical_bytes(); let source_len = ScoreVersion::default().0.len(); assert_eq!(source_len, 32, "ScoreVersion source is 32 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()); - // 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). let catalog_len = { let mut c = Vec::new(); @@ -422,8 +438,8 @@ mod tests { }; assert_eq!( bytes.len(), - source_len + 4 * 4 + catalog_len, - "four u32 count prefixes (16 bytes), not u64 (32 bytes)" + source_len + 5 * 4 + catalog_len, + "five u32 count prefixes (20 bytes), not u64 (40 bytes)" ); } diff --git a/crates/epiphany-layout-ir/src/roundtrip.rs b/crates/epiphany-layout-ir/src/roundtrip.rs index 6a3ccf8..73062d6 100644 --- a/crates/epiphany-layout-ir/src/roundtrip.rs +++ b/crates/epiphany-layout-ir/src/roundtrip.rs @@ -70,9 +70,12 @@ pub struct RoundTripReport { pub glyphs: usize, /// Stroke primitives (staff lines, stems, markers, …). 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, - /// 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, } @@ -161,7 +164,8 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT .glyphs .iter() .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 { assert_eq!( @@ -226,6 +230,10 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT report.layout.strokes, constrained.strokes, "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( @@ -235,7 +243,8 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT .glyphs .iter() .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 // provenance. A conformant solver may additionally *synthesize* objects of @@ -284,13 +293,18 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT render.strokes, report.layout.strokes, "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( "render", render .primitives .iter() .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!( resolved_map, render_map, @@ -298,23 +312,24 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT ); // Provenance back to graph identity: the recovered source *set* — over every - // primitive, glyph and stroke — is exactly the set laid out (a surjection: - // every source recovered, nothing spurious), while the primitive count equals - // the distinct-stable-id count, so each layout object (and each synthesized - // derived primitive) is represented exactly once. + // primitive (glyph, stroke, and curve) — is exactly the set laid out (a + // surjection: every source recovered, nothing spurious), while the primitive + // count equals the distinct-stable-id count, so each layout object (and each + // synthesized derived primitive) is represented exactly once. let expected = laid_out_object_ids(score); let recovered: BTreeSet = render .primitives .iter() .map(|p| p.provenance.source) .chain(render.strokes.iter().map(|s| s.provenance.source)) + .chain(render.curves.iter().map(|c| c.provenance.source)) .collect(); assert_eq!( expected, recovered, "RenderIR sources do not match the laid-out graph objects" ); assert_eq!( - render.primitives.len() + render.strokes.len(), + render.primitives.len() + render.strokes.len() + render.curves.len(), render_map.len(), "render produced two primitives with the same stable id" ); @@ -329,7 +344,8 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT + logical.cross_region.len(), glyphs: constrained.glyphs.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, } } @@ -493,7 +509,7 @@ mod tests { let report = round_trip(&valid_score(seed)); assert_eq!( 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 // surjection). A source may back several primitives now — a staff's diff --git a/crates/epiphany-layout-ir/src/solver.rs b/crates/epiphany-layout-ir/src/solver.rs index 1e684e0..25f77bd 100644 --- a/crates/epiphany-layout-ir/src/solver.rs +++ b/crates/epiphany-layout-ir/src/solver.rs @@ -474,13 +474,18 @@ impl StubSolver { } else { Vec::new() }; - // Strokes pass through verbatim (the stub resolves no geometry), gated on - // the same structural validity as the glyphs. + // Strokes and curves pass through verbatim (the stub resolves no + // geometry), gated on the same structural validity as the glyphs. let strokes = if structural_valid { input.strokes.clone() } else { Vec::new() }; + let curves = if structural_valid { + input.curves.clone() + } else { + Vec::new() + }; let resolved_glyphs = glyphs.len(); let pages = input .regions @@ -515,6 +520,7 @@ impl StubSolver { pages, glyphs, strokes, + curves, engraving_decisions: input.engraving_decisions.clone(), catalog: input.catalog.clone(), }, @@ -609,6 +615,7 @@ mod tests { }], glyphs, strokes: vec![], + curves: vec![], vertical_bands: vec![band], constraints: vec![], break_origins: vec![], @@ -696,6 +703,7 @@ mod tests { }], glyphs: vec![unknown], strokes: vec![], + curves: vec![], vertical_bands: vec![band], constraints: vec![], break_origins: vec![], diff --git a/crates/epiphany-render-svg/DECISIONS.md b/crates/epiphany-render-svg/DECISIONS.md index b420e9e..c060724 100644 --- a/crates/epiphany-render-svg/DECISIONS.md +++ b/crates/epiphany-render-svg/DECISIONS.md @@ -114,3 +114,15 @@ diff is content-only). `GlyphClass` gained a `Repeat` class (token `repeat`) so snapshots and `data-class` attributes separate repeat signs from plain barlines. Volta ending numerals arrive as `timeSig` digit glyphs (there is still no free-text primitive — unchanged). + +## Curve primitive → stroked ``, `GlyphClass` unaffected (E2, 2026-07-08) + +A resolved `Curve` (slur) emits a stroked, unfilled cubic-bézier `` elements emitted (one per resolved stroke: staff line, stem, …). pub stroke_count: usize, + /// `` curve elements emitted (one per resolved curve: slur, …). + pub curve_count: usize, /// Elements carrying a `data-prov` trace back to a score-graph source. pub provenance_count: usize, /// Distinct layers, each rendered as one `` group. @@ -239,6 +241,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut text_count: 0, fallback_rect_count: 0, stroke_count: 0, + curve_count: 0, provenance_count: 0, layer_count: 0, class_counts, @@ -262,9 +265,14 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut for (i, stroke) in resolved.strokes.iter().enumerate() { stroke_layers.entry(stroke.layer).or_default().push(i); } + let mut curve_layers: BTreeMap> = 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 = glyph_layers .keys() .chain(stroke_layers.keys()) + .chain(curve_layers.keys()) .copied() .collect(); @@ -272,6 +280,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut let mut text_count = 0; let mut fallback_rect_count = 0; let mut stroke_count = 0; + let mut curve_count = 0; let mut provenance_count = 0; 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 ``. + 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, + " ", + 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) { for &i in indices { let g = &resolved.glyphs[i]; @@ -440,6 +482,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut text_count, fallback_rect_count, stroke_count, + curve_count, provenance_count, layer_count: layer_ids.len(), 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 { 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]` /// (within an `f32` tolerance). SVG transforms are affine, so a non-affine /// (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. fn provenance_note(emit_provenance: bool) -> &'static str { 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 { "provenance traces suppressed (display-only output, not archival)" } @@ -925,6 +995,7 @@ mod tests { pages: vec![], glyphs: vec![], strokes: vec![], + curves: vec![], engraving_decisions: vec![], catalog: Default::default(), }; diff --git a/crates/epiphany-render-svg/tests/acceptance.rs b/crates/epiphany-render-svg/tests/acceptance.rs index 4e916f4..9139953 100644 --- a/crates/epiphany-render-svg/tests/acceptance.rs +++ b/crates/epiphany-render-svg/tests/acceptance.rs @@ -41,6 +41,10 @@ fn fixtures() -> Vec<(&'static str, Score)> { "ten_measure_with_repeats", 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 )); 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!( "provenance_count={}\n", out.stats.provenance_count @@ -146,8 +151,8 @@ fn fixtures_render_to_golden_locked_svg_and_snapshot() { ); assert_eq!( out.stats.provenance_count, - out.stats.glyph_count + out.stats.stroke_count, - "{fixture}: every drawn glyph and stroke must carry a provenance trace" + out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count, + "{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace" ); let class_sum: usize = out.stats.class_counts.values().sum(); assert_eq!( @@ -310,8 +315,8 @@ fn engraver_output_is_golden_locked_well_formed_with_every_glyph_drawn() { ); assert_eq!( out.stats.provenance_count, - out.stats.glyph_count + out.stats.stroke_count, - "{fixture}: every drawn glyph and stroke must carry a provenance trace" + out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count, + "{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace" ); assert!( out.diagnostics.is_empty(), diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt index 5621923..86ad279 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=51 path_count=51 fallback_rect_count=0 stroke_count=96 +curve_count=0 provenance_count=147 layer_count=1 hard_constraint_count=90 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg index 5477f96..04e3d96 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt index 91ab969..5349cbe 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=51 path_count=51 fallback_rect_count=0 stroke_count=91 +curve_count=0 provenance_count=142 layer_count=1 hard_constraint_count=90 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg index c11f5c3..3634f29 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt index fa36f7e..d647a4b 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=56 path_count=56 fallback_rect_count=0 stroke_count=105 +curve_count=0 provenance_count=161 layer_count=1 hard_constraint_count=95 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg index 092b59b..3fd96ec 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt index 4408d4b..cc135ab 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=56 path_count=56 fallback_rect_count=0 stroke_count=100 +curve_count=0 provenance_count=156 layer_count=1 hard_constraint_count=95 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg index 0c9cbbe..115f34c 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt new file mode 100644 index 0000000..3a51a2f --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.snapshot.txt @@ -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 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg new file mode 100644 index 0000000..c88129f --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.snapshot.txt new file mode 100644 index 0000000..cb437a1 --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.snapshot.txt @@ -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 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg new file mode 100644 index 0000000..b3c6e0e --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt index 613aa77..a09bb42 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=11 path_count=11 fallback_rect_count=0 stroke_count=38 +curve_count=0 provenance_count=49 layer_count=1 hard_constraint_count=15 diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg index 61663e7..3af7c31 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt index de82ec7..715e9cd 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt @@ -3,6 +3,7 @@ glyph_count=11 path_count=11 fallback_rect_count=0 stroke_count=38 +curve_count=0 provenance_count=49 layer_count=1 hard_constraint_count=15 diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg index be002e1..425141c 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index 40ac8e9..4711b1b 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -11,17 +11,19 @@ use epiphany_core::{ AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal, - CrossCuttingRegistry, Event, EventArena, EventDuration, EventPosition, IdentifiedPitch, - IdentityContext, Marker, Measure, MeasurePosition, MetricTimeModel, MusicalDuration, - MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, - RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure, ScalePosition, Score, Spanner, Staff, - StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, - TieClass, TimeAnchor, TimeExtent, TuningReference, Voice, Volta, WallClockTime, + CrossCuttingRegistry, CurvatureOverride, CurveDirection, Event, EventArena, EventDuration, + EventPosition, IdentifiedPitch, IdentityContext, Marker, Measure, MeasurePosition, + MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, + RationalTime, RegionContent, RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure, + ScalePosition, Score, Slur, SlurKind, SpaceUnit, SpanStyle, Spanner, Staff, StaffBasedContent, + StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, TieClass, + TimeAnchor, TimeExtent, TuningReference, Voice, Volta, WallClockTime, }; use epiphany_core::{ ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, RepeatStructureId, ReplicaId, SlurId, SpannerId, StaffId, StaffInstanceId, TieId, VoiceId, }; +use epiphany_determinism::CanonicalF64; use epiphany_determinism::fuzz::SplitMix64; @@ -257,6 +259,48 @@ pub fn ten_measure_with_repeats(seed: u64) -> 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 = score.canvas.regions[0].staff_instances()[0].voices[0] + .events + .clone(); + + score.cross_cutting.slurs.push(Slur { + id: score.identity.mint::(), + 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::(), + 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::(), + start_event: events[10], + end_event: events[12], + kind: SlurKind::Editorial, + curvature_override: None, + style: SpanStyle::default(), + }); + score +} + #[cfg(test)] mod tests { 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 /// anchor's target) among its invalidation dependencies, so a time-signature /// display change with an unchanged id invalidates the measure and its diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 4267f41..204d29a 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -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 /// (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 @@ -389,6 +405,7 @@ pub fn gen_constrained_layout_ir(rng: &mut Rng) -> ConstrainedLayoutIR { strokes: (0..rng.range_usize(0, 3)) .map(|_| gen_stroke(rng)) .collect(), + curves: (0..rng.range_usize(0, 3)).map(|_| gen_curve(rng)).collect(), vertical_bands: vec![band], constraints: vec![], break_origins: vec![], @@ -489,13 +506,15 @@ pub fn gen_round_trip_report(rng: &mut Rng) -> RoundTripReport { .iter() .map(|primitive| primitive.provenance.source) .chain(render.strokes.iter().map(|stroke| stroke.provenance.source)) + .chain(render.curves.iter().map(|curve| curve.provenance.source)) .collect(); - let total = render.primitives.len() + render.strokes.len(); + let total = render.primitives.len() + render.strokes.len() + render.curves.len(); RoundTripReport { status: SolveStatus::Solved, logical_objects: total, glyphs: render.primitives.len(), render_strokes: render.strokes.len(), + render_curves: render.curves.len(), render_primitives: total, recovered_sources, }