diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 21a5349..377a6c1 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -3241,6 +3241,7 @@ mod tests { .zip(&line_provenance) .map(|(&y, provenance)| Stroke { provenance: provenance.clone(), + vertical_band: epiphany_layout_ir::VerticalBandId(0), from: Point::new(0.0, y), to: Point::new(88.0, y), thickness: StaffSpace(0.1), diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index e7a8ab4..f06b757 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -707,49 +707,68 @@ constrained stage stacks at a fixed pitch — separate. `ENGRAVER_VERSION` 10 11 (a multi-staff score whose staves press together shifts them apart; a single-staff score, with no inter-staff pair, is byte-identical). -**Attribution (`vertical_band` + owning-glyph).** Every resolved primitive is -attributed to its owning staff: a glyph via its `vertical_band` -(`VerticalBandKind::Staff` → `StaffId`); a ledger via its notehead -(`owning_glyph`, a shared `Pitch` source); a **stem** — which has no same-source -glyph — via the glyph nearest its BASE point *in two dimensions*; a staff line -via its `Staff` provenance source; a **curve** via its arc direction against the -staff-line bands (below). Spacing is horizontal-only, so a primitive's y is -unchanged from the source frame the attribution reads, and the resolved and -source indices line up. (The geometric `round(-y / pitch)` alternative was -rejected: an extreme ledgered note on the top or bottom staff rounds to a -non-existent neighbour.) +**Attribution is DECLARED, not inferred.** Every primitive — glyph, stroke, +curve — carries a `vertical_band`, and the solve reads its owning staff straight +out of it (`VerticalBandKind::Staff` → `StaffId`). Content owned by no staff +names a non-`Staff` band and is attributed to `None`, taking no staff shift. +There is no geometry in the attribution path at all: no distances, no epsilons, +no fallbacks. The projection that emitted the primitive already knew the answer — +a stem's band is its note's, a slur's is its notes' — so it says so. +(The geometric `round(-y / pitch)` alternative was rejected early: an extreme +ledgered note on the top or bottom staff rounds to a non-existent neighbour.) -**Why staff attribution must be y-aware (review fix).** The first cut reused -`component_glyph`, whose fallback picks the nearest glyph **by x alone**. That is -right for a *slot* — both staves of a system share their x columns, hence their -spring slots, so the horizontal delta is the same either way — but it is wrong -for a *staff*: it handed a lower-staff stem to the UPPER staff's notehead, so the -stem kept the wrong vertical shift and tore off its own head by several staff -spaces (and polluted the upper staff's content extent, inflating the computed -gap). The staff attribution now uses a 2-D nearest for that fallback. -`component_glyph` is unchanged and still serves the horizontal path. Locked by -`multi_staff_stems_stay_on_their_own_staff` (a stem's base sits at its notehead -on both the single- and multi-staff fixtures). A related correction: staff- -attributed primitives contribute their y ONLY through the shifted path -(`Extent::add_x` for x, `add_y` for the shifted staff extent), so a lower staff's -unshifted content can no longer inflate a system's `max_y`. +**Why: inferring the owner from proximity failed twice, in ways a gate missed.** +Both bugs shipped into a committed golden and were caught only by review. -**Why a curve cannot use ANY nearest-glyph rule (second review fix).** A slur is -the same bug class one layer deeper, and no distance metric can fix it. A slur's -start endpoint is deliberately *lifted off* its notes — `staff_top + gap` above, -`staff_bottom - gap` below — into the inter-staff zone, where the nearest glyph -is frequently a note on the ADJACENT staff (in `two_staff_close_content`, a top- -staff ledger note). The bottom staff's slur was therefore attributed to the top -staff, kept shift 0, and tore off its own notes — baked into the golden, -byte-identical to the pre-solve slur path. The rule is now the one that reads the -geometry as drawn: **the arc's direction picks the side.** `p1.y >= p0.y` means -the curve arcs upward, so it hangs BELOW a staff → take the staff whose staff- -line band bottom is greatest among those at or above `p0.y`; otherwise it arcs -downward, sitting ABOVE a staff → take the staff whose band top is smallest among -those at or below `p0.y`. Falls back to the nearest band mid-line when neither -side matches (a curve inside a staff). Locked by `a_slur_travels_with_its_own_staff` -(the slur's endpoint band-gap to the bottom staff is smaller than to the top, and -under a staff space of margin). +1. *Stems.* The first cut reused `component_glyph`, whose fallback picks the + nearest glyph **by x alone**. That is right for a *slot* — both staves of a + system share their x columns, hence their spring slots, so the horizontal + delta is the same either way — but wrong for a *staff*: it handed a lower-staff + stem to the UPPER staff's notehead, so the stem kept the wrong vertical shift + and tore off its own head by several staff spaces (and polluted the upper + staff's content extent, inflating the computed gap). Patched with a 2-D + nearest. +2. *Slurs.* The same class one layer deeper, and unfixable by any distance + metric. A slur's start endpoint is deliberately *lifted off* its notes — + `staff_top + gap` above, `staff_bottom - gap` below — into the inter-staff + zone, where the nearest glyph is frequently a note on the ADJACENT staff (in + `two_staff_close_content`, a top-staff ledger note). The bottom staff's slur + was attributed to the top staff, kept shift 0, and tore off its own notes. + Patched with an arc-direction rule read against the staff-line bands. + +Both patches were *correct* and both were the wrong shape: they reconstructed, by +geometric inference, a fact the projection had in hand and discarded. `Stroke` +and `Curve` now declare `vertical_band` exactly as `GlyphObject` always has, and +the two rules above are deleted. The engraver's attribution is three map lookups. +Locked by `multi_staff_stems_stay_on_their_own_staff` and +`a_slur_travels_with_its_own_staff` (both kept — they now assert an outcome the +data model *guarantees*, which is where a regression would surface if the +declaration were ever dropped), and by layout-ir's +`every_stroke_and_curve_names_a_band_that_exists`. Adopting it churned **no +golden**: the declared owner agrees with the inferred one on every fixture. + +A related correction from the same review: staff-attributed primitives contribute +their y ONLY through the shifted path (`Extent::add_x` for x, `add_y` for the +shifted staff extent), so a lower staff's unshifted content can no longer inflate +a system's `max_y`. + +**What the band model had to grow to carry this.** Two bands were previously +emitted only when a *glyph* needed them, which left strokes naming bands that did +not exist (validation now rejects that outright, as `UnknownBand`): + +- A **staff band** is emitted for every staff of the region, in the region's own + staff order — the order `y_origin` stacks by — rather than only for staves that + emitted a glyph. A staff whose clef is unbundled engraves to an anchor *stroke* + and no glyph, and would otherwise have had no band. +- The **margin band** is emitted unconditionally. A region's own traced anchor is + a stroke, and it names the margin band whether or not any region-level glyph + puts a member in it. + +Both may carry zero members, as an inter-staff gap band already did: band +*membership* drives the spring solve over glyphs; band *existence* is what +attribution needs. Strokes and curves are deliberately NOT added to +`VerticalBand::members` — their band reference is one-way, validated only to name +a real band. **Known gaps (reviewed, not bugs today).** @@ -762,12 +781,16 @@ under a staff space of margin). refinement scoring only the EXCESS beyond the content-required minimum is the deferred follow-up. Asserted, not silently tolerated, in `inter_staff_solve_separates_colliding_staves`. -- **Staff-less content does not move.** Margin-band glyphs, and spanning strokes - whose source is `RepeatStructure` (volta brackets), attribute to no staff, take - shift 0, and stay put while the staves below them descend. Symmetrically, a - volta stroke that happens to sit near a notehead is attributed to *that* staff - by the 2-D nearest fallback — possibly the wrong one. No fixture exercises - either; both want the band model to carry the attribution outright. +- **Staff-less content takes no staff shift** — margin-band glyphs, and a volta + bracket whose repeat spans several staves. It stays put while the staves below + it descend, which is right for content that sits above the top staff (the top + staff's shift is always 0). A volta anchored to a *single* staff declares that + staff's band and moves with it. This used to be an accident of geometry — a + volta near a notehead was dragged onto that notehead's staff by the nearest- + glyph fallback — and is now a declared property; see "Attribution is DECLARED" + above. What remains undecided is genuinely staff-less content placed *between* + two staves: it holds still while the lower staff descends away from it. That + wants a height model for the inter-staff gap band, not an attribution rule. - The preferred gap is read from `VerticalBand::inter_staff_gap(VerticalBandId(0))` rather than from the region's *declared* inter-staff band. Harmless while both come from the same constructor; it would silently diverge from the metric the diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index 7037b31..bd2c1e5 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -638,10 +638,20 @@ pub(crate) fn cast_off( // Attribute every primitive to its owning staff so the gaps BETWEEN a // system's staves can be renegotiated: the constrained stage stacks staves // at a fixed pitch, so tightly ledgered or slurred adjacent staves collide. - // Glyphs name their staff via their vertical band; a stem/ledger tracks its - // notehead; a staff line names its staff; a slur takes the staff of the - // note nearest its start. Spacing is horizontal-only, so a primitive's y is - // unchanged from the source frame the attribution is computed in. + // + // Attribution is a BAND LOOKUP, not a geometric guess. Every primitive — + // glyph, stroke, curve — declares the vertical band it belongs to, and the + // projection that emitted it knew the answer: a stem's band is its note's, a + // slur's is its notes'. Content owned by no staff (a page-margin annotation, + // a repeat structure spanning several staves) names a non-`Staff` band and + // is attributed to `None` — it takes no staff shift. + // + // Inferring the owner from proximity instead is a trap this code fell into + // twice. A stem sits under its notehead but shares x columns with the staff + // above; a slur's endpoints are lifted clear of its own staff by design, so + // the nearest notehead is routinely on the ADJACENT staff. Neither is + // recoverable from geometry, and both silently tore primitives off their + // notes. See DECISIONS.md, "Why attribution is declared, not inferred". let band_to_staff: BTreeMap = input .vertical_bands .iter() @@ -650,86 +660,21 @@ pub(crate) fn cast_off( _ => None, }) .collect(); - let glyph_band_staff = |g: &GlyphObject| band_to_staff.get(&g.vertical_band).copied(); - let glyph_staff_of: Vec> = input.glyphs.iter().map(glyph_band_staff).collect(); - // The glyph nearest a point in TWO dimensions. Attribution to a *staff* must - // be y-aware: `component_glyph`'s horizontal fallback picks the nearest glyph - // by x alone, which is right for a slot (both staves of a system share their - // x columns, hence their slots) but would hand a stem to the ADJACENT staff. - let nearest_glyph = |x: f32, y: f32| -> Option<&GlyphObject> { - input.glyphs.iter().min_by(|a, b| { - let d = |g: &GlyphObject| (g.baseline.x.0 - x).powi(2) + (g.baseline.y.0 - y).powi(2); - d(a).total_cmp(&d(b)) - }) - }; + let staff_of = |band: VerticalBandId| band_to_staff.get(&band).copied(); + let glyph_staff_of: Vec> = input + .glyphs + .iter() + .map(|g| staff_of(g.vertical_band)) + .collect(); let stroke_staff_of: Vec> = input .strokes .iter() - .map(|s| match s.provenance.source { - // A staff line names its staff outright. - TypedObjectId::Staff(st) => Some(st), - // A ledger shares its notehead's `Pitch` source (`owning_glyph`); - // a stem has no same-source glyph, so it takes the staff of the - // glyph nearest its BASE (`from`, which sits at the notehead). - _ => owning_glyph(s, &input.glyphs) - .or_else(|| nearest_glyph(s.from.x.0, s.from.y.0)) - .and_then(glyph_band_staff), - }) + .map(|s| staff_of(s.vertical_band)) .collect(); - // Each staff's staff-line band, in the source frame (a staff's lines sit at - // the same y in every system, since the constrained stage stacks them once). - let mut staff_lines: BTreeMap = BTreeMap::new(); - for s in &input.strokes { - if let TypedObjectId::Staff(st) = s.provenance.source { - let (lo, hi) = (s.from.y.0.min(s.to.y.0), s.from.y.0.max(s.to.y.0)); - staff_lines - .entry(st) - .and_modify(|e| { - e.0 = e.0.min(lo); - e.1 = e.1.max(hi); - }) - .or_insert((lo, hi)); - } - } - // A slur's staff. Its endpoints are LIFTED clear of their own staff — an - // above-slur sits `STAFF_HEIGHT + gap` over the top line, a below-slur a gap - // under the bottom one — so they land in the inter-staff zone and the - // notehead nearest `p0` can belong to the ADJACENT staff (the same trap the - // stroke attribution fell into, and a WORSE one: the lift is by design, so - // no distance metric can recover the staff). Use the arc's direction, which - // the control point gives (`p1.y > p0.y` ⇔ above), against the staff-line - // bands: an ABOVE slur belongs to the nearest staff whose top line is at or - // below its endpoints; a BELOW slur to the nearest staff whose bottom line is - // at or above them. That is exact for any lift, with no constants shared. - let eps = 1e-3_f32; let curve_staff_of: Vec> = input .curves .iter() - .map(|c| { - let p0y = c.p0.y.0; - let above = c.p1.y.0 >= p0y; - let picked = if above { - staff_lines - .iter() - .filter(|(_, (_, hi))| *hi <= p0y + eps) - .max_by(|a, b| a.1 .1.total_cmp(&b.1 .1)) - } else { - staff_lines - .iter() - .filter(|(_, (lo, _))| *lo >= p0y - eps) - .min_by(|a, b| a.1 .0.total_cmp(&b.1 .0)) - }; - // A slur clear of every staff on its arc side (no staff below an - // above-slur) falls back to the staff whose band is nearest. - picked - .or_else(|| { - staff_lines.iter().min_by(|a, b| { - let d = |e: &(f32, f32)| (((e.0 + e.1) * 0.5) - p0y).abs(); - d(a.1).total_cmp(&d(b.1)) - }) - }) - .map(|(st, _)| *st) - }) + .map(|c| staff_of(c.vertical_band)) .collect(); // Pass A: system extents (unshifted), and per (system, staff) content @@ -1070,6 +1015,7 @@ pub(crate) fn cast_off( thickness: spaced.thickness, layer: spaced.layer, style: spaced.style, + vertical_band: spaced.vertical_band, }; if let TypedObjectId::Staff(staff) = spaced.provenance.source { mark_staff(&mut staff_marks, *s, staff, &stroke); @@ -1138,6 +1084,7 @@ pub(crate) fn cast_off( thickness: curve.thickness, layer: curve.layer, style: curve.style, + vertical_band: curve.vertical_band, line: curve.line, }; if k == 0 { @@ -1712,6 +1659,7 @@ fn translated(stroke: &Stroke, dx: f32, dy: f32) -> Stroke { thickness: stroke.thickness, layer: stroke.layer, style: stroke.style, + vertical_band: stroke.vertical_band, } } @@ -1782,6 +1730,7 @@ fn place_stroke( thickness: spaced.thickness, layer: spaced.layer, style: spaced.style, + vertical_band: spaced.vertical_band, } } diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 907a528..06bbbd0 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -546,6 +546,7 @@ impl HorizontalRemap { thickness: s.thickness, layer: s.layer, style: s.style, + vertical_band: s.vertical_band, } }) .collect() @@ -571,6 +572,7 @@ impl HorizontalRemap { layer: c.layer, style: c.style, line: c.line, + vertical_band: c.vertical_band, } }) .collect() @@ -1518,8 +1520,10 @@ mod tests { // must not leak into canonical_bytes or the renderer. let mut input = fixture(); let provenance = input.glyphs[0].provenance.clone(); + let band = input.glyphs[0].vertical_band; input.strokes.push(epiphany_layout_ir::Stroke { provenance, + vertical_band: band, from: epiphany_layout_ir::Point::new(0.0, 0.0), to: epiphany_layout_ir::Point::new(1.0, 0.0), thickness: epiphany_layout_ir::StaffSpace(f32::MAX), diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 3466e6a..13484d9 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -641,3 +641,42 @@ out of scope), so these are E2 decisions for the Phase-F ratification pass: 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. + +## Strokes and curves declare their vertical band (2026-07-09) + +`Stroke` and `Curve` gained `vertical_band: VerticalBandId`, the field +`GlyphObject` has always carried. Previously the doc comment on `Curve` called it +"a *free* primitive (no vertical band, no spring slot)", and the projection +computed each primitive's band, used it for the glyphs, and threw it away for the +strokes and curves. + +- **Why.** A vertical solver has to know which staff owns a primitive. With the + band discarded, `epiphany-engrave` reconstructed it geometrically — nearest + glyph to a stem's base, arc direction against the staff-line bands for a slur — + and got it *wrong twice*, tearing stems and then slurs off their own notes, in + bugs that reached a committed golden. A slur is the proof the inference can + never be made safe: its endpoints are deliberately lifted clear of its own + staff, into the zone where the nearest notehead belongs to the neighbour. The + projection knows the owner (a slur's staff is its notes' staff). It now says so. +- **One-way reference.** A stroke/curve is NOT added to `VerticalBand::members`. + Membership realizes the spring solve over *glyphs*; the band reference on a + line primitive is a declaration of ownership. Validation therefore enforces + only that the named band exists (`UnknownBand`) — a dangling reference would + silently drop the primitive out of a vertical solve. +- **Two bands became unconditional.** A staff band is now emitted for every staff + of the region, in the region's own staff order (the order `y_origin` stacks + by), not only for staves that emitted a glyph — a staff whose clef is unbundled + engraves to an anchor *stroke* and no glyph. The margin band is now emitted + even with no members, because a region's own traced anchor is a stroke that + names it. Empty bands were already normal (an inter-staff gap band has no + members). Locked by `every_stroke_and_curve_names_a_band_that_exists`. +- **Non-canonical.** `ResolvedLayoutIR::canonical_bytes` encodes primitives + field-by-field and does not encode `vertical_band` (as it does not encode a + glyph's). So this is layout metadata, outside the canonical encoding: no + companion-version bump, and no golden churn — adopting it left every rendered + byte identical, which is what proved the declared owner agrees with the + inferred one on the whole corpus. +- **Staff-less content.** A repeat structure spanning several staves, or a + page-margin annotation, names a non-`Staff` band and is owned by no staff. A + cross-staff slur is not drawn at this tier (`staff.is_some()` guards the curve; + it engraves to an anchor stroke), so a drawn curve always names a staff band. diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 7a37298..214080c 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -86,6 +86,17 @@ pub struct Stroke { pub thickness: StaffSpace, pub layer: i32, pub style: GlyphStyle, + /// The vertical band this stroke belongs to, declared by the projection that + /// emitted it — the same band its owning object's glyphs declare. A vertical + /// solver reads *this*, never the stroke's geometry: a stem, a ledger line, + /// and a staff line all name their staff outright, so no consumer has to + /// guess an owner from proximity. Unlike a glyph, a stroke is not listed in + /// [`VerticalBand::members`] (band membership drives the spring solve over + /// glyphs); this is a one-way reference, validated only to name a real band. + /// + /// Content owned by no staff — a page-margin annotation, a repeat structure + /// spanning several staves — names the region's margin band. + pub vertical_band: VerticalBandId, } impl Stroke { @@ -97,11 +108,12 @@ 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. +/// one of these; ties and other span curves will follow. Like [`Stroke`] it +/// holds no spring slot — the solver re-spaces its control points by the +/// horizontal coordinate map, exactly as it does a spanning stroke's endpoints — +/// but it does declare its [`vertical_band`](Curve::vertical_band). 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, @@ -115,6 +127,17 @@ pub struct Curve { /// The line pattern the renderer strokes the path with (a slur's authored /// `SpanStyle.line`). Solid, dashed, or dotted. pub line: LineStyle, + /// The vertical band this curve belongs to — see [`Stroke::vertical_band`]. + /// + /// This matters more for a curve than for any other primitive. A slur's + /// endpoints are *lifted clear* of its own staff by construction (an above- + /// slur sits a staff height plus a gap over the top line), so they land in + /// the inter-staff zone where the nearest notehead can belong to the + /// ADJACENT staff. No geometric rule recovers the owner. The projection + /// knows it — a slur's staff is the staff of its notes — so it declares it. + /// A slur whose notes span two staves is owned by neither and names the + /// margin band. + pub vertical_band: VerticalBandId, } impl Curve { @@ -538,6 +561,15 @@ impl ConstrainedLayoutIR { stroke.id(), )); } + // A stroke's band is a one-way reference (it is not in `members`), + // so the only thing to enforce is that it names a band that exists — + // a dangling one would silently drop the stroke out of the vertical + // solve's attribution. + if !band_ids.contains(&stroke.vertical_band) { + return Err(ConstrainedValidationError::UnknownBand( + stroke.vertical_band, + )); + } } for curve in &self.curves { @@ -551,6 +583,9 @@ impl ConstrainedLayoutIR { if !geometry_quantizes || curve.thickness.0 < 0.0 { return Err(ConstrainedValidationError::InvalidCurveGeometry(curve.id())); } + if !band_ids.contains(&curve.vertical_band) { + return Err(ConstrainedValidationError::UnknownBand(curve.vertical_band)); + } } Ok(()) } @@ -686,9 +721,11 @@ const VOLTA_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x564F_4C54_414 /// monotonic — which is what lets a real solver re-space glyphs *and* the strokes /// that track them by a single coordinate map. /// -/// Glyphs (only) are routed to the band of their own staff (Chapter 7 §"Vertical -/// Bands"); strokes are free line primitives the solver positions but the band -/// model does not contain. Structural objects with no Minimal-tier glyph +/// Every primitive — glyph, stroke, curve — is routed to the band of its own +/// staff (Chapter 7 §"Vertical Bands"), so a vertical solver reads a primitive's +/// owner rather than inferring it from geometry. Only glyphs become band +/// *members* (membership realizes the spring solve); a stroke's or curve's band +/// is a one-way declaration. Structural objects with no Minimal-tier glyph /// (regions, voices, ties, slurs, beams, …) are carried as zero-extent traced /// anchors so provenance survives, pending their engraving in a higher tier. /// @@ -1114,6 +1151,7 @@ pub fn try_to_constrained( Point::new(staff_left, y), Point::new(staff_right, y), STAFF_LINE_THICKNESS, + band_of(staff), )); } } @@ -1164,7 +1202,11 @@ pub fn try_to_constrained( } None => { emit.diag(provenance.source, unbundled(clef_label(clef.shape))); - emit.stroke(anchor(provenance, Point::new(default_x, yo))); + emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )); } } } @@ -1173,7 +1215,11 @@ pub fn try_to_constrained( let segs = event_stems.get(&eid).map(Vec::as_slice).unwrap_or(&[]); if segs.is_empty() { // A pitch-less, component-less note still needs its anchor. - emit.stroke(anchor(provenance, Point::new(default_x, yo))); + emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )); } for seg in segs { let info = column(&seg.key); @@ -1195,13 +1241,18 @@ pub fn try_to_constrained( thickness: StaffSpace(STEM_THICKNESS), layer: 0, style: ink(), + vertical_band: band_of(staff), }); } } Some(LayoutContent::Rest(_)) => { let segs = event_rests.get(&eid).map(Vec::as_slice).unwrap_or(&[]); if segs.is_empty() { - emit.stroke(anchor(provenance, Point::new(default_x, yo))); + emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )); } for seg in segs { let info = column(&seg.key); @@ -1227,14 +1278,22 @@ pub fn try_to_constrained( // keeps its place; later components do not vanish. None => { emit.diag(prov_ref.source, unbundled(rest_label())); - emit.stroke(anchor(prov_ref, Point::new(info.x, seg.y))); + emit.stroke(anchor( + prov_ref, + Point::new(info.x, seg.y), + band_of(staff), + )); } } } } // A non-pitched, non-rest event (unpitched / trajectory / cue): // not engraved in this tier; a traced anchor keeps it. - _ => emit.stroke(anchor(provenance, Point::new(default_x, yo))), + _ => emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )), }, TypedObjectId::Pitch(pid) => match pitch_heads.get(&pid) { Some(heads) => { @@ -1278,6 +1337,7 @@ pub fn try_to_constrained( Point::new(info.x + head_left - LEDGER_LINE_EXTENSION, y), Point::new(info.x + head_right + LEDGER_LINE_EXTENSION, y), STAFF_LINE_THICKNESS, + band_of(staff), )); } // The spelling's accidental stack: synthesized glyphs @@ -1312,6 +1372,7 @@ pub fn try_to_constrained( emit.stroke(anchor( provenance, Point::new(default_x, step_to_y(yo, reference_step(&clef))), + band_of(staff), )); } }, @@ -1392,7 +1453,11 @@ pub fn try_to_constrained( // (uniform with every other cross-cutting structure); all // of its ink — the standalone signs below and the volta // brackets here — is synthesized from it. - emit.stroke(anchor(provenance, Point::new(default_x, yo))); + emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )); let Some(LayoutContent::Repeat(repeat)) = content else { continue; }; @@ -1429,6 +1494,7 @@ pub fn try_to_constrained( Point::new(start.x, y), Point::new(end.x, y), VOLTA_LINE_THICKNESS, + band_of(staff), )); for (element, x) in [(1u128, start.x), (2u128, end.x)] { emit.stroke(line_stroke( @@ -1436,6 +1502,7 @@ pub fn try_to_constrained( Point::new(x, y), Point::new(x, y - VOLTA_HOOK), VOLTA_LINE_THICKNESS, + band_of(staff), )); } let mut cursor = start.x + VOLTA_TEXT_X; @@ -1469,19 +1536,27 @@ pub fn try_to_constrained( // 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) + slur_curve(provenance, slur, yo, &columns, band_of(staff)) } _ => None, }; match curve { Some(curve) => emit.curve(curve), - None => emit.stroke(anchor(provenance, Point::new(default_x, yo))), + None => emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )), } } // Region, Voice, GraphicObject, and every other cross-cutting // 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))), + _ => emit.stroke(anchor( + provenance, + Point::new(default_x, yo), + band_of(staff), + )), } } @@ -1747,23 +1822,29 @@ pub fn try_to_constrained( }); } - // A staff band per manifested staff that carries glyphs, in first-glyph - // order; an (empty) inter-staff gap band between adjacent staves; and a - // margin band for any region-level glyphs. - for staff in &staves_in_order { + // A staff band per staff of the region, in the region's own staff order — + // the order `y_origin` stacks by, and exactly the set `band_of` can name. + // (Driving this off the staves that emitted *glyphs* would leave a + // stroke-only staff — one whose clef glyph is unbundled, so it engraves + // to an anchor stroke — naming a band that does not exist.) An (empty) + // inter-staff gap band sits between adjacent staves. + // + // The margin band is emitted unconditionally: a region's own traced + // anchor is a *stroke*, and it names the margin band whether or not any + // region-level glyph does. Both bands may carry no members; so may a gap + // band. Membership drives the spring solve over glyphs, not existence. + for staff in &staff_order { let layout_id = manifestation_layout_id(&TypedObjectId::Staff(*staff), region_id); let members = staff_members.remove(staff).unwrap_or_default(); vertical_bands.push(VerticalBand::staff_manifestation( layout_id, *staff, members, )); } - for gap in 1..staves_in_order.len() { + for gap in 1..staff_order.len() { let gap_id = inter_staff_gap_id(region_layout_id, gap); vertical_bands.push(VerticalBand::inter_staff_gap(gap_id)); } - if !margin_members.is_empty() { - vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members)); - } + vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members)); constrained_regions.push(ConstrainedLayoutRegion { provenance: region.provenance.clone(), glyphs: region_glyphs, @@ -1883,8 +1964,8 @@ struct ColumnInfo { note_column: bool, } -/// The accumulators a region's engraving emits into. Glyphs (only) carry band and -/// spring-slot membership; strokes and curves are free line primitives. +/// The accumulators a region's engraving emits into. Every primitive declares its +/// vertical band; glyphs alone carry band *membership* and a spring slot. struct Emit<'a> { glyphs: &'a mut Vec, strokes: &'a mut Vec, @@ -1981,7 +2062,13 @@ fn ink() -> GlyphStyle { } /// A solid black line stroke between two points. -fn line_stroke(provenance: Provenance, from: Point, to: Point, thickness: f32) -> Stroke { +fn line_stroke( + provenance: Provenance, + from: Point, + to: Point, + thickness: f32, + band: VerticalBandId, +) -> Stroke { Stroke { provenance, from, @@ -1989,12 +2076,13 @@ fn line_stroke(provenance: Provenance, from: Point, to: Point, thickness: f32) - thickness: StaffSpace(thickness), layer: 0, style: ink(), + vertical_band: band, } } /// A zero-extent, zero-width stroke at `at`: an invisible traced anchor that /// keeps a structural object (with no Minimal-tier glyph) provenance-tracked. -fn anchor(provenance: &Provenance, at: Point) -> Stroke { +fn anchor(provenance: &Provenance, at: Point, band: VerticalBandId) -> Stroke { Stroke { provenance: provenance.clone(), from: at, @@ -2002,6 +2090,7 @@ fn anchor(provenance: &Provenance, at: Point) -> Stroke { thickness: StaffSpace(0.0), layer: 0, style: ink(), + vertical_band: band, } } @@ -2152,6 +2241,7 @@ fn slur_curve( slur: &SlurContent, yo: f32, columns: &BTreeMap, + band: VerticalBandId, ) -> Option { let start_x = slur_endpoint_x(&slur.start, columns)?; let end_x = slur_endpoint_x(&slur.end, columns)?; @@ -2210,6 +2300,9 @@ fn slur_curve( // The authored line pattern is rendered faithfully (dashed/dotted), // not deferred — the renderer strokes the path with it. line: slur.line, + // The slur's OWN staff band — its notes' band, not whichever staff its + // lifted endpoints happen to land nearest. + vertical_band: band, }) } @@ -2769,12 +2862,60 @@ mod tests { ); } + /// Every stroke and curve names a band that exists. A vertical solver reads + /// that band to find a primitive's owning staff, so a dangling reference + /// would silently drop the primitive out of the solve — it would keep its + /// source y while its staff moved, tearing off its notes. + /// + /// Two bands exist precisely so nothing dangles. A staff band is emitted for + /// every staff of the region, not only those that emitted a glyph (a staff + /// whose clef is unbundled engraves to an anchor *stroke* and no glyph). The + /// margin band is emitted unconditionally, because a region's own traced + /// anchor is a stroke that names it whether or not a margin glyph does. + #[test] + fn every_stroke_and_curve_names_a_band_that_exists() { + use epiphany_core::generators::valid_score; + for (label, score) in [ + ("valid_score_rich", valid_score_rich(11)), + ("valid_score", valid_score(4)), + ] { + let c = to_constrained(&to_logical(&score)); + let bands: BTreeSet = c.vertical_bands.iter().map(|b| b.id).collect(); + assert!(!bands.is_empty(), "{label}: the projection emits bands"); + for stroke in &c.strokes { + assert!( + bands.contains(&stroke.vertical_band), + "{label}: stroke {:?} names a band that does not exist", + stroke.id() + ); + } + for curve in &c.curves { + assert!( + bands.contains(&curve.vertical_band), + "{label}: curve {:?} names a band that does not exist", + curve.id() + ); + } + // The region's own anchor stroke is staff-less, so a margin band must + // exist even when no region-level *glyph* put a member in it. + assert!( + c.vertical_bands + .iter() + .any(|b| b.kind == crate::VerticalBandKind::MarginBand), + "{label}: a margin band exists for the region-level anchor" + ); + assert!(c.validate().is_ok(), "{label}: the projection validates"); + } + } + #[test] fn out_of_range_finite_stroke_thickness_is_rejected() { let mut c = to_constrained(&to_logical(&valid_score_rich(11))); let provenance = c.glyphs[0].provenance.clone(); + let band = c.glyphs[0].vertical_band; c.strokes.push(Stroke { provenance, + vertical_band: band, from: Point::new(0.0, 0.0), to: Point::new(1.0, 0.0), // Finite but far outside the canonical 1/1024 grid range: it passes a diff --git a/crates/epiphany-layout-ir/src/hittest.rs b/crates/epiphany-layout-ir/src/hittest.rs index 2cc8f76..19746eb 100644 --- a/crates/epiphany-layout-ir/src/hittest.rs +++ b/crates/epiphany-layout-ir/src/hittest.rs @@ -524,6 +524,7 @@ mod tests { fn stroke(from: Point, to: Point, layer: i32) -> Stroke { Stroke { provenance: Provenance::projected(TypedObjectId::Staff(StaffId::from_raw(1)), vec![]), + vertical_band: crate::VerticalBandId(0), from, to, thickness: StaffSpace(0.2), @@ -631,6 +632,7 @@ mod tests { let slur = SlurId::new(epiphany_core::ReplicaId(3), 9); let curve = Curve { provenance: Provenance::projected(TypedObjectId::Slur(slur), vec![]), + vertical_band: crate::VerticalBandId(0), p0: Point::new(0.0, 0.0), p1: Point::new(1.0, 2.0), p2: Point::new(3.0, 2.0), diff --git a/crates/epiphany-layout-ir/src/solver.rs b/crates/epiphany-layout-ir/src/solver.rs index 25f77bd..5c510a2 100644 --- a/crates/epiphany-layout-ir/src/solver.rs +++ b/crates/epiphany-layout-ir/src/solver.rs @@ -737,6 +737,7 @@ mod tests { .canonical_bytes(); input.strokes.push(crate::Stroke { provenance: input.glyphs[0].provenance.clone(), + vertical_band: input.glyphs[0].vertical_band, from: crate::Point::new(0.0, 0.0), to: crate::Point::new(1.5, 0.0), thickness: crate::StaffSpace(0.13), diff --git a/crates/epiphany-render-svg/src/svg.rs b/crates/epiphany-render-svg/src/svg.rs index 8f74de9..7f8c74f 100644 --- a/crates/epiphany-render-svg/src/svg.rs +++ b/crates/epiphany-render-svg/src/svg.rs @@ -799,6 +799,7 @@ mod tests { // one more and checks it renders and traces on top of them. let base_strokes = layout.strokes.len(); layout.strokes.push(Stroke { + vertical_band: epiphany_layout_ir::VerticalBandId(0), provenance: layout.glyphs[0].provenance.clone(), from: Point::new(0.0, 0.0), to: Point::new(4.0, 0.0), @@ -833,6 +834,7 @@ mod tests { let mut layout = stub_layout(11); let prov = layout.glyphs[0].provenance.clone(); let make = |line| Curve { + vertical_band: epiphany_layout_ir::VerticalBandId(0), provenance: prov.clone(), p0: Point::new(0.0, 0.0), p1: Point::new(1.0, 2.0), @@ -906,6 +908,7 @@ mod tests { let provenance = layout.glyphs[0].provenance.clone(); layout.glyphs.clear(); layout.strokes.push(Stroke { + vertical_band: epiphany_layout_ir::VerticalBandId(0), provenance, from: Point::new(0.0, 0.0), to: Point::new(4.0, 0.0), diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 1e4f3e7..639829c 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -333,7 +333,7 @@ pub fn gen_logical_layout_ir(rng: &mut Rng) -> LogicalLayoutIR { /// A non-glyph line primitive (a staff line / stem / barline), so the /// generator/fuzz surface exercises strokes alongside glyphs. -pub fn gen_stroke(rng: &mut Rng) -> Stroke { +pub fn gen_stroke(rng: &mut Rng, band: VerticalBandId) -> Stroke { Stroke { provenance: gen_provenance(rng), from: gen_point(rng), @@ -343,11 +343,13 @@ pub fn gen_stroke(rng: &mut Rng) -> Stroke { style: GlyphStyle { rgba: rng.next_u64() as u32, }, + // A stroke names a band that exists; validation rejects a dangling one. + vertical_band: band, } } /// A cubic-bézier curve primitive with generated provenance and geometry. -pub fn gen_curve(rng: &mut Rng) -> Curve { +pub fn gen_curve(rng: &mut Rng, band: VerticalBandId) -> Curve { Curve { provenance: gen_provenance(rng), p0: gen_point(rng), @@ -364,6 +366,8 @@ pub fn gen_curve(rng: &mut Rng) -> Curve { epiphany_core::LineStyle::Dashed, epiphany_core::LineStyle::Dotted, ]), + // A curve names a band that exists; validation rejects a dangling one. + vertical_band: band, } } @@ -408,9 +412,11 @@ pub fn gen_constrained_layout_ir(rng: &mut Rng) -> ConstrainedLayoutIR { horizontal_slots, glyphs, strokes: (0..rng.range_usize(0, 3)) - .map(|_| gen_stroke(rng)) + .map(|_| gen_stroke(rng, band.id)) + .collect(), + curves: (0..rng.range_usize(0, 3)) + .map(|_| gen_curve(rng, band.id)) .collect(), - curves: (0..rng.range_usize(0, 3)).map(|_| gen_curve(rng)).collect(), vertical_bands: vec![band], constraints: vec![], break_origins: vec![],