Let the band model carry staff attribution instead of inferring it
Stroke and Curve gain `vertical_band: VerticalBandId`, the field GlyphObject has always carried. Curve's own doc comment used to call it "a *free* primitive (no vertical band, no spring slot)" -- but the projection computed each primitive's band, used it for the glyphs, and threw it away for the strokes and curves. The engraver then reconstructed it geometrically, and got it wrong twice: nearest glyph by x handed a lower-staff stem to the upper staff (4132a7a), and nearest glyph to a slur's start endpoint handed a bottom-staff slur to the top staff (b1bfe04). Both tore primitives off their own notes, both reached a committed golden, both were caught by review rather than by the gate. 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. No distance metric recovers the owner. Both fixes were correct and both were the wrong shape -- reconstructing by inference a fact the projection had in hand and discarded. So: the projection declares it (a slur's staff is its notes' staff), the engraver's attribution becomes three map lookups, and ~60 lines of geometric rules, epsilons, and fallbacks are deleted. Two bands had to become unconditional, since strokes could otherwise name bands that no glyph had caused to exist -- validation now rejects that as UnknownBand: - a staff band per 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 unconditionally, because a region's own traced anchor is a stroke that names it whether or not a margin glyph puts a member in it. Both may carry zero members, as an inter-staff gap band already did: membership realizes the spring solve over glyphs; existence is what attribution needs. Strokes and curves are deliberately NOT added to VerticalBand::members -- their band reference is one-way. vertical_band is not part of ResolvedLayoutIR::canonical_bytes (primitives are encoded field-by-field), so this is layout metadata outside the canonical encoding: no companion-version bump, and ENGRAVER_VERSION stays at 11 because the output is unchanged. Zero goldens churn -- which is the evidence that the declared owner agrees with the inferred one across the entire corpus. Locked by every_stroke_and_curve_names_a_band_that_exists, verified by mutation: making the margin band conditional again fails it on valid_score_rich. The two tear-off regressions are kept -- they now assert an outcome the data model guarantees, which is where a dropped declaration would surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7350362a3
commit
efaebb9acd
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<VerticalBandId, StaffId> = 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<Option<StaffId>> = 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<Option<StaffId>> = input
|
||||
.glyphs
|
||||
.iter()
|
||||
.map(|g| staff_of(g.vertical_band))
|
||||
.collect();
|
||||
let stroke_staff_of: Vec<Option<StaffId>> = 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<StaffId, (f32, f32)> = 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<Option<StaffId>> = 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<GlyphObject>,
|
||||
strokes: &'a mut Vec<Stroke>,
|
||||
|
|
@ -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<ColumnKey, ColumnInfo>,
|
||||
band: VerticalBandId,
|
||||
) -> Option<Curve> {
|
||||
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<VerticalBandId> = 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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
Loading…
Reference in New Issue