696 lines
28 KiB
Rust
696 lines
28 KiB
Rust
//! Stage 2 — `ConstrainedLayoutIR` (Chapter 7 §"ConstrainedLayoutIR").
|
|
//!
|
|
//! The output of the spacing pass: the logical IR with composite objects
|
|
//! flattened to individual glyphs, each glyph carrying the anchor geometry that
|
|
//! is the constraint solver's input. v0 lays glyphs out left-to-right on the
|
|
//! canonical `1/1024` grid, assigns each region's glyphs to a vertical band
|
|
//! (Chapter 7 §"Vertical Bands"), carries the engraving decisions forward, and
|
|
//! stamps the catalog with a metrics hash over exactly the glyphs it references
|
|
//! (Chapter 7 §7.3.2), and emits the spring-slot and constraint interfaces the
|
|
//! solver consumes. The geometry here is what the stub solver returns verbatim.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use epiphany_core::{StaffId, TypedObjectId, WallClockTime};
|
|
|
|
use crate::engraving::EngravingDecision;
|
|
use crate::glyph::{
|
|
metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference, BRAVURA_METRICS,
|
|
};
|
|
use crate::logical::{LogicalLayoutIR, ScoreVersion};
|
|
use crate::provenance::{manifestation_layout_id, LayoutObjectId, Provenance};
|
|
use crate::solver::SpringSlotId;
|
|
use crate::spatial::{BoundingBox, Point, Rect, StaffSpace};
|
|
use crate::time_axis::{SlotPlacement, TimeAxisModel, TimePoint};
|
|
use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId};
|
|
|
|
/// A stable identifier for a glyph-level object (Chapter 7: `GlyphObjectId`).
|
|
/// Shares the glyph's provenance `stable_id`, so it is stable across relayouts.
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
|
pub struct GlyphObjectId(pub u128);
|
|
|
|
/// A glyph with a baseline anchor, the input to the solver (Chapter 7
|
|
/// §"Glyph-Level Objects"). v0 references the glyph by SMuFL name and queries
|
|
/// its metrics from the in-tree catalog ([`crate::glyph`]); the `baseline` is
|
|
/// the staff-space geometry the stub solver returns verbatim.
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct GlyphObject {
|
|
pub provenance: Provenance,
|
|
/// The SMuFL glyph whose metrics the solver consults.
|
|
pub glyph: GlyphReference,
|
|
/// Horizontal spring slot containing this glyph.
|
|
pub horizontal_slot: SpringSlotId,
|
|
pub baseline: Point,
|
|
/// The vertical band this glyph belongs to (Chapter 7 §"Glyph-Level
|
|
/// Objects": every glyph names exactly one `vertical_band`).
|
|
pub vertical_band: VerticalBandId,
|
|
pub bounding_box: BoundingBox,
|
|
pub anchor: Point,
|
|
pub layer: i32,
|
|
pub style: GlyphStyle,
|
|
}
|
|
|
|
impl GlyphObject {
|
|
/// This glyph's stable id (Chapter 7: `GlyphObjectId`).
|
|
pub fn id(&self) -> GlyphObjectId {
|
|
GlyphObjectId(self.provenance.stable_id.0)
|
|
}
|
|
}
|
|
|
|
/// The constrained IR: composite objects flattened to glyphs, with the vertical
|
|
/// bands and engraving decisions that the solver consumes alongside them
|
|
/// (Chapter 7 §"Constraints").
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct ConstrainedLayoutIR {
|
|
pub source: ScoreVersion,
|
|
pub regions: Vec<ConstrainedLayoutRegion>,
|
|
pub horizontal_slots: Vec<SpringSlot>,
|
|
pub glyphs: Vec<GlyphObject>,
|
|
pub vertical_bands: Vec<VerticalBand>,
|
|
pub constraints: Vec<LayoutConstraint>,
|
|
pub engraving_decisions: Vec<EngravingDecision>,
|
|
pub catalog: GlyphCatalogIdentity,
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct GlyphStyle {
|
|
/// RGBA color in `0xRRGGBBAA` form.
|
|
pub rgba: u32,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct ConstrainedLayoutRegion {
|
|
pub provenance: Provenance,
|
|
pub glyphs: Vec<GlyphObjectId>,
|
|
/// The region's time axis, populated with the time→slot placements of this
|
|
/// region's spring slots (Chapter 7 §"The Time Axis"): `time_axis.project`
|
|
/// maps a musical/wall-clock time to the slot covering it.
|
|
pub time_axis: TimeAxisModel,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct SpringSlot {
|
|
pub id: SpringSlotId,
|
|
pub time: TimePoint,
|
|
pub min_width: StaffSpace,
|
|
pub preferred_width: StaffSpace,
|
|
pub max_width: Option<StaffSpace>,
|
|
pub stretch_factor: f32,
|
|
pub compress_factor: f32,
|
|
pub members: Vec<GlyphObjectId>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum Axis {
|
|
Horizontal,
|
|
Vertical,
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum BreakKind {
|
|
Hard,
|
|
Soft,
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
|
pub struct ConstraintRegistryId(pub u128);
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct ConstraintParameters(pub Vec<u8>);
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub enum LayoutConstraint {
|
|
NoCollision {
|
|
a: GlyphObjectId,
|
|
b: GlyphObjectId,
|
|
},
|
|
Align {
|
|
a: GlyphObjectId,
|
|
b: GlyphObjectId,
|
|
axis: Axis,
|
|
},
|
|
PositionWithin {
|
|
glyph: GlyphObjectId,
|
|
region: Rect,
|
|
},
|
|
SystemBreakAt {
|
|
slot: SpringSlotId,
|
|
kind: BreakKind,
|
|
},
|
|
PageBreakAt {
|
|
slot: SpringSlotId,
|
|
kind: BreakKind,
|
|
},
|
|
Registered(ConstraintRegistryId, ConstraintParameters),
|
|
}
|
|
|
|
/// A structural defect in [`ConstrainedLayoutIR`] that prevents a solver from
|
|
/// treating the input as a valid constraint problem.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum ConstrainedValidationError {
|
|
DuplicateGlyphId(GlyphObjectId),
|
|
DuplicateBandId(VerticalBandId),
|
|
UnknownBand(VerticalBandId),
|
|
UnknownBandMember(GlyphObjectId),
|
|
DuplicateBandMember(GlyphObjectId),
|
|
BandMismatch(GlyphObjectId),
|
|
InvalidGeometry(GlyphObjectId),
|
|
InvalidBandGeometry(VerticalBandId),
|
|
DuplicateSlotId(SpringSlotId),
|
|
UnknownSlot(SpringSlotId),
|
|
UnknownSlotMember(GlyphObjectId),
|
|
DuplicateSlotMember(GlyphObjectId),
|
|
SlotMismatch(GlyphObjectId),
|
|
InvalidSlotGeometry(SpringSlotId),
|
|
InvalidGlyphBounds(GlyphObjectId),
|
|
/// A constraint references a glyph that is not in the glyph set.
|
|
UnknownConstraintGlyph(GlyphObjectId),
|
|
/// A break constraint references a spring slot that does not exist.
|
|
UnknownConstraintSlot(SpringSlotId),
|
|
/// A `PositionWithin` constraint carries a non-finite or inverted region.
|
|
InvalidConstraintRegion(GlyphObjectId),
|
|
}
|
|
|
|
/// A malformed logical-stage value that cannot be transformed without losing
|
|
/// content.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum LayoutTransformError {
|
|
RegionSourceIsNotRegion(LayoutObjectId),
|
|
CrossRegionObjectHasNoRegion(LayoutObjectId),
|
|
}
|
|
|
|
impl ConstrainedLayoutIR {
|
|
/// Validates the cross-reference and finite-geometry invariants consumed by
|
|
/// every constraint solver. Invalid public values are rejected before a
|
|
/// solver can report `Solved` or emit non-canonical geometry.
|
|
pub fn validate(&self) -> Result<(), ConstrainedValidationError> {
|
|
let mut glyphs_by_id = BTreeMap::new();
|
|
for glyph in &self.glyphs {
|
|
let id = glyph.id();
|
|
if glyphs_by_id.insert(id, glyph).is_some() {
|
|
return Err(ConstrainedValidationError::DuplicateGlyphId(id));
|
|
}
|
|
let bounds = glyph.bounding_box;
|
|
let valid_bounds = [bounds.left.0, bounds.bottom.0, bounds.right.0, bounds.top.0]
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
&& bounds.left.0 <= bounds.right.0
|
|
&& bounds.bottom.0 <= bounds.top.0;
|
|
if !valid_bounds {
|
|
return Err(ConstrainedValidationError::InvalidGlyphBounds(id));
|
|
}
|
|
if glyph.baseline.quantize().is_none() || glyph.anchor.quantize().is_none() {
|
|
return Err(ConstrainedValidationError::InvalidGeometry(id));
|
|
}
|
|
}
|
|
|
|
let mut slot_ids = BTreeSet::new();
|
|
let mut slot_memberships = BTreeMap::new();
|
|
for slot in &self.horizontal_slots {
|
|
if !slot_ids.insert(slot.id) {
|
|
return Err(ConstrainedValidationError::DuplicateSlotId(slot.id));
|
|
}
|
|
let min = slot.min_width.0;
|
|
let preferred = slot.preferred_width.0;
|
|
let max_valid = match slot.max_width {
|
|
Some(maximum) => maximum.0.is_finite() && maximum.0 >= preferred,
|
|
None => true,
|
|
};
|
|
if !min.is_finite()
|
|
|| !preferred.is_finite()
|
|
|| min < 0.0
|
|
|| preferred < min
|
|
|| !max_valid
|
|
|| !slot.stretch_factor.is_finite()
|
|
|| !slot.compress_factor.is_finite()
|
|
|| slot.stretch_factor < 0.0
|
|
|| slot.compress_factor < 0.0
|
|
{
|
|
return Err(ConstrainedValidationError::InvalidSlotGeometry(slot.id));
|
|
}
|
|
for member in &slot.members {
|
|
let Some(glyph) = glyphs_by_id.get(member) else {
|
|
return Err(ConstrainedValidationError::UnknownSlotMember(*member));
|
|
};
|
|
if slot_memberships.insert(*member, slot.id).is_some() {
|
|
return Err(ConstrainedValidationError::DuplicateSlotMember(*member));
|
|
}
|
|
if glyph.horizontal_slot != slot.id {
|
|
return Err(ConstrainedValidationError::SlotMismatch(*member));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut band_ids = BTreeSet::new();
|
|
let mut memberships = BTreeMap::new();
|
|
for band in &self.vertical_bands {
|
|
if !band_ids.insert(band.id) {
|
|
return Err(ConstrainedValidationError::DuplicateBandId(band.id));
|
|
}
|
|
let min = band.min_height.0;
|
|
let preferred = band.preferred_height.0;
|
|
let max = band.max_height.map(|height| height.0);
|
|
let valid_heights = min.is_finite()
|
|
&& preferred.is_finite()
|
|
&& min >= 0.0
|
|
&& preferred >= min
|
|
&& match max {
|
|
Some(maximum) => maximum.is_finite() && maximum >= preferred,
|
|
None => true,
|
|
};
|
|
if !valid_heights
|
|
|| !band.stretch_factor.is_finite()
|
|
|| !band.compress_factor.is_finite()
|
|
|| band.stretch_factor < 0.0
|
|
|| band.compress_factor < 0.0
|
|
{
|
|
return Err(ConstrainedValidationError::InvalidBandGeometry(band.id));
|
|
}
|
|
for member in &band.members {
|
|
let Some(glyph) = glyphs_by_id.get(member) else {
|
|
return Err(ConstrainedValidationError::UnknownBandMember(*member));
|
|
};
|
|
if memberships.insert(*member, band.id).is_some() {
|
|
return Err(ConstrainedValidationError::DuplicateBandMember(*member));
|
|
}
|
|
if glyph.vertical_band != band.id {
|
|
return Err(ConstrainedValidationError::BandMismatch(*member));
|
|
}
|
|
}
|
|
}
|
|
|
|
for glyph in &self.glyphs {
|
|
if !slot_ids.contains(&glyph.horizontal_slot) {
|
|
return Err(ConstrainedValidationError::UnknownSlot(
|
|
glyph.horizontal_slot,
|
|
));
|
|
}
|
|
if slot_memberships.get(&glyph.id()) != Some(&glyph.horizontal_slot) {
|
|
return Err(ConstrainedValidationError::SlotMismatch(glyph.id()));
|
|
}
|
|
if !band_ids.contains(&glyph.vertical_band) {
|
|
return Err(ConstrainedValidationError::UnknownBand(glyph.vertical_band));
|
|
}
|
|
if memberships.get(&glyph.id()) != Some(&glyph.vertical_band) {
|
|
return Err(ConstrainedValidationError::BandMismatch(glyph.id()));
|
|
}
|
|
}
|
|
|
|
// Constraints must reference objects that exist: a dangling glyph or
|
|
// slot reference is a malformed problem, not a silently-accepted one.
|
|
let glyph_exists = |id: GlyphObjectId| -> bool { glyphs_by_id.contains_key(&id) };
|
|
for constraint in &self.constraints {
|
|
match constraint {
|
|
LayoutConstraint::NoCollision { a, b } | LayoutConstraint::Align { a, b, .. } => {
|
|
if !glyph_exists(*a) {
|
|
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*a));
|
|
}
|
|
if !glyph_exists(*b) {
|
|
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*b));
|
|
}
|
|
}
|
|
LayoutConstraint::PositionWithin { glyph, region } => {
|
|
if !glyph_exists(*glyph) {
|
|
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*glyph));
|
|
}
|
|
let r = [
|
|
region.origin.x.0,
|
|
region.origin.y.0,
|
|
region.size.width.0,
|
|
region.size.height.0,
|
|
];
|
|
let region_ok = r.iter().all(|v| v.is_finite())
|
|
&& region.size.width.0 >= 0.0
|
|
&& region.size.height.0 >= 0.0;
|
|
if !region_ok {
|
|
return Err(ConstrainedValidationError::InvalidConstraintRegion(*glyph));
|
|
}
|
|
}
|
|
LayoutConstraint::SystemBreakAt { slot, .. }
|
|
| LayoutConstraint::PageBreakAt { slot, .. } => {
|
|
if !slot_ids.contains(slot) {
|
|
return Err(ConstrainedValidationError::UnknownConstraintSlot(*slot));
|
|
}
|
|
}
|
|
// A Registered (extension) constraint is opaque; treated
|
|
// conservatively (not rejected) per "Behavior Under Unknown
|
|
// Extensions".
|
|
LayoutConstraint::Registered(_, _) => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Picks a bundled SMuFL glyph for a source, deterministically (a pure function
|
|
/// of the source kind, so it never depends on traversal position).
|
|
pub(crate) fn glyph_name_for(source: &TypedObjectId) -> GlyphReference {
|
|
GlyphReference::borrowed(
|
|
BRAVURA_METRICS[(source.discriminant() as usize) % BRAVURA_METRICS.len()]
|
|
.name
|
|
.as_ref(),
|
|
)
|
|
}
|
|
|
|
/// Flattens [`LogicalLayoutIR`] into [`ConstrainedLayoutIR`]: one glyph per
|
|
/// layout object (including the region object itself), each laid out
|
|
/// left-to-right on the `1/1024` grid, with provenance preserved
|
|
/// object-for-object.
|
|
///
|
|
/// **Each glyph is routed to the band of its own staff** (Chapter 7 §"Vertical
|
|
/// Bands"): a region manifesting two staves gets a staff band per staff, with
|
|
/// each glyph a member of exactly its staff's band — never every staff's band.
|
|
/// Region-level glyphs (the region object, cross-cutting, free-graphic) go to a
|
|
/// margin band. Multi-staff regions also carry empty `InterStaffGap` spring
|
|
/// bands between consecutive staves. Staff-band ids are the staff *layout
|
|
/// object's* manifestation id, so a staff manifested in two regions gets two
|
|
/// distinct bands.
|
|
pub fn to_constrained(logical: &LogicalLayoutIR) -> ConstrainedLayoutIR {
|
|
try_to_constrained(logical).expect("LogicalLayoutIR is malformed")
|
|
}
|
|
|
|
/// Fallible form of [`to_constrained`] for callers accepting externally built
|
|
/// logical IR. It rejects malformed provenance rather than silently dropping a
|
|
/// region or spanning object.
|
|
pub fn try_to_constrained(
|
|
logical: &LogicalLayoutIR,
|
|
) -> Result<ConstrainedLayoutIR, LayoutTransformError> {
|
|
let mut glyphs = Vec::new();
|
|
let mut vertical_bands = Vec::new();
|
|
let mut horizontal_slots = Vec::new();
|
|
let mut constrained_regions = Vec::new();
|
|
let mut column: i64 = 0;
|
|
|
|
for region in &logical.regions {
|
|
let region_id = match region.provenance.source {
|
|
TypedObjectId::Region(id) => id,
|
|
_ => {
|
|
return Err(LayoutTransformError::RegionSourceIsNotRegion(
|
|
region.provenance.stable_id,
|
|
))
|
|
}
|
|
};
|
|
let region_layout_id = region.provenance.stable_id;
|
|
let band_of = |staff: Option<StaffId>| -> VerticalBandId {
|
|
match staff {
|
|
Some(s) => {
|
|
VerticalBandId(manifestation_layout_id(&TypedObjectId::Staff(s), region_id).0)
|
|
}
|
|
None => VerticalBandId(region_layout_id.0),
|
|
}
|
|
};
|
|
|
|
// (provenance, owning staff) for the region object, then its contents.
|
|
let mut specs: Vec<(&Provenance, Option<StaffId>)> =
|
|
std::iter::once((®ion.provenance, None))
|
|
.chain(region.objects.iter().map(|o| (o.provenance(), o.staff())))
|
|
.collect();
|
|
specs.extend(
|
|
logical
|
|
.cross_region
|
|
.iter()
|
|
.filter(|object| object.regions.first() == Some(®ion_id))
|
|
.map(|object| (&object.provenance, object.staff)),
|
|
);
|
|
|
|
// Distinct staves in first-appearance order, and members per band.
|
|
let mut staves_in_order: Vec<StaffId> = Vec::new();
|
|
let mut staff_members: BTreeMap<StaffId, Vec<GlyphObjectId>> = BTreeMap::new();
|
|
let mut margin_members: Vec<GlyphObjectId> = Vec::new();
|
|
let mut region_glyphs = Vec::new();
|
|
let mut region_placements: Vec<SlotPlacement> = Vec::new();
|
|
|
|
for (provenance, staff) in specs {
|
|
let band = band_of(staff);
|
|
let glyph = make_glyph(provenance, column, band);
|
|
column += 1;
|
|
let gid = glyph.id();
|
|
let time = TimePoint::WallClock(WallClockTime(column - 1));
|
|
horizontal_slots.push(SpringSlot {
|
|
id: glyph.horizontal_slot,
|
|
time: time.clone(),
|
|
min_width: StaffSpace(1.0),
|
|
preferred_width: StaffSpace(1.5),
|
|
max_width: None,
|
|
stretch_factor: 1.0,
|
|
compress_factor: 1.0,
|
|
members: vec![gid],
|
|
});
|
|
region_placements.push(SlotPlacement {
|
|
time,
|
|
slot: glyph.horizontal_slot,
|
|
});
|
|
region_glyphs.push(gid);
|
|
match staff {
|
|
Some(s) => {
|
|
if !staves_in_order.contains(&s) {
|
|
staves_in_order.push(s);
|
|
}
|
|
staff_members.entry(s).or_default().push(gid);
|
|
}
|
|
None => margin_members.push(gid),
|
|
}
|
|
glyphs.push(glyph);
|
|
}
|
|
|
|
// A staff band per manifested staff, in first-appearance order.
|
|
for staff in &staves_in_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,
|
|
));
|
|
}
|
|
// An (empty) inter-staff gap band between each pair of adjacent staves.
|
|
for gap in 1..staves_in_order.len() {
|
|
let gap_id = inter_staff_gap_id(region_layout_id, gap);
|
|
vertical_bands.push(VerticalBand::inter_staff_gap(gap_id));
|
|
}
|
|
// A margin band for region-level glyphs, if any.
|
|
if !margin_members.is_empty() {
|
|
vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members));
|
|
}
|
|
constrained_regions.push(ConstrainedLayoutRegion {
|
|
provenance: region.provenance.clone(),
|
|
glyphs: region_glyphs,
|
|
// The region's kind-only logical axis, now populated with the real
|
|
// time→slot placements resolved during spacing.
|
|
time_axis: region.time_axis.clone().with_placements(region_placements),
|
|
});
|
|
}
|
|
|
|
let names: Vec<&str> = glyphs.iter().map(|glyph| glyph.glyph.as_str()).collect();
|
|
let catalog = BravuraCatalog.identity(&names);
|
|
if let Some(object) = logical
|
|
.cross_region
|
|
.iter()
|
|
.find(|object| object.regions.is_empty())
|
|
{
|
|
return Err(LayoutTransformError::CrossRegionObjectHasNoRegion(
|
|
object.provenance.stable_id,
|
|
));
|
|
}
|
|
|
|
Ok(ConstrainedLayoutIR {
|
|
source: logical.source,
|
|
regions: constrained_regions,
|
|
horizontal_slots,
|
|
glyphs,
|
|
vertical_bands,
|
|
constraints: Vec::new(),
|
|
engraving_decisions: logical.engraving_decisions.clone(),
|
|
catalog,
|
|
})
|
|
}
|
|
|
|
/// Builds a glyph for a provenance at horizontal `column`, baseline one staff
|
|
/// space apart per column (Chapter 7 §7.2 staff-space coordinates), in `band`.
|
|
fn make_glyph(provenance: &Provenance, column: i64, band: VerticalBandId) -> GlyphObject {
|
|
let glyph = glyph_name_for(&provenance.source);
|
|
GlyphObject {
|
|
bounding_box: metrics(glyph.as_str())
|
|
.expect("pipeline glyph names are bundled")
|
|
.bounding_box(),
|
|
glyph,
|
|
horizontal_slot: SpringSlotId(provenance.stable_id.0),
|
|
baseline: Point::new(column as f32, 0.0),
|
|
vertical_band: band,
|
|
anchor: Point::ORIGIN,
|
|
layer: 0,
|
|
style: GlyphStyle { rgba: 0x0000_00ff },
|
|
provenance: provenance.clone(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::logical::to_logical;
|
|
use crate::time_axis::TimeAxis;
|
|
use epiphany_core::generators::valid_score_rich;
|
|
use std::collections::BTreeSet;
|
|
|
|
#[test]
|
|
fn spacing_populates_a_consumable_time_axis_per_region() {
|
|
let c = to_constrained(&to_logical(&valid_score_rich(11)));
|
|
assert!(!c.regions.is_empty());
|
|
for region in &c.regions {
|
|
// The axis is no longer inert: it carries one placement per slot the
|
|
// region produced, and the slots come back in time order.
|
|
let region_slots: Vec<SpringSlotId> =
|
|
region.glyphs.iter().map(|g| SpringSlotId(g.0)).collect();
|
|
assert_eq!(region.time_axis.slots(), region_slots);
|
|
|
|
// project() consumes the time argument: each slot's own time projects
|
|
// back to that slot (the covering placement) — not a constant.
|
|
for placement in region.time_axis.placements() {
|
|
assert_eq!(
|
|
region.time_axis.project(placement.time.clone()),
|
|
placement.slot
|
|
);
|
|
}
|
|
// A non-trivial region distinguishes its slots by time (so project is
|
|
// genuinely a function of the query, not "always the first slot").
|
|
if region.time_axis.placements().len() >= 2 {
|
|
let p = region.time_axis.placements();
|
|
assert_ne!(
|
|
region.time_axis.project(p[0].time.clone()),
|
|
region.time_axis.project(p[1].time.clone())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Band membership is a correct partition: every glyph names an existing
|
|
/// band, no glyph is a member of two bands, and a glyph's `vertical_band`
|
|
/// equals the band that lists it — so a glyph is never placed in another
|
|
/// staff's band.
|
|
#[test]
|
|
fn glyphs_are_routed_to_exactly_their_band() {
|
|
for seed in 0..48u64 {
|
|
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
|
|
let band_ids: BTreeSet<_> = c.vertical_bands.iter().map(|b| b.id).collect();
|
|
|
|
let mut member_band: BTreeMap<GlyphObjectId, VerticalBandId> = BTreeMap::new();
|
|
for b in &c.vertical_bands {
|
|
for m in &b.members {
|
|
assert!(
|
|
member_band.insert(*m, b.id).is_none(),
|
|
"a glyph is a member of two bands"
|
|
);
|
|
}
|
|
}
|
|
for g in &c.glyphs {
|
|
assert!(
|
|
band_ids.contains(&g.vertical_band),
|
|
"glyph names an unknown band"
|
|
);
|
|
assert_eq!(
|
|
member_band.get(&g.id()),
|
|
Some(&g.vertical_band),
|
|
"glyph is not a member of the band it names"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A two-staff region yields a staff band per staff (no cross-staff
|
|
/// contamination) plus an inter-staff gap band; each staff's glyphs land in
|
|
/// that staff's band only.
|
|
#[test]
|
|
fn multi_staff_region_routes_per_staff_with_a_gap_band() {
|
|
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR};
|
|
use crate::provenance::Provenance;
|
|
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
|
|
use crate::vertical_band::VerticalBandKind;
|
|
use epiphany_core::{EventId, RegionId, StaffId};
|
|
|
|
let region = RegionId::from_raw(1);
|
|
let region_src = TypedObjectId::Region(region);
|
|
let staff_a = StaffId::from_raw(10);
|
|
let staff_b = StaffId::from_raw(20);
|
|
let manifested = |src: TypedObjectId, staff: StaffId| {
|
|
LayoutObject::from_projection(Provenance::manifested(src, region, vec![]), Some(staff))
|
|
};
|
|
let logical = LogicalLayoutIR {
|
|
source: ScoreVersion::default(),
|
|
regions: vec![LayoutRegion {
|
|
provenance: Provenance::projected(region_src, vec![]),
|
|
coordinate_system: crate::LocalCoordinateSystem::default(),
|
|
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
|
|
vertical_extent: crate::VerticalExtent {
|
|
staves: vec![staff_a, staff_b],
|
|
},
|
|
objects: vec![
|
|
manifested(TypedObjectId::Staff(staff_a), staff_a),
|
|
manifested(TypedObjectId::Staff(staff_b), staff_b),
|
|
manifested(TypedObjectId::Event(EventId::from_raw(1)), staff_a),
|
|
manifested(TypedObjectId::Event(EventId::from_raw(2)), staff_b),
|
|
],
|
|
}],
|
|
engraving_decisions: vec![],
|
|
overrides: vec![],
|
|
cross_region: vec![],
|
|
};
|
|
let c = to_constrained(&logical);
|
|
|
|
let staff_bands: Vec<_> = c
|
|
.vertical_bands
|
|
.iter()
|
|
.filter(|b| matches!(b.kind, VerticalBandKind::Staff(_)))
|
|
.collect();
|
|
let gap_bands = c
|
|
.vertical_bands
|
|
.iter()
|
|
.filter(|b| matches!(b.kind, VerticalBandKind::InterStaffGap))
|
|
.count();
|
|
assert_eq!(staff_bands.len(), 2, "one staff band per staff");
|
|
assert_eq!(gap_bands, 1, "one inter-staff gap band between two staves");
|
|
|
|
// Staff A's two glyphs (staff object + event) are in A's band only.
|
|
let band_a = staff_bands
|
|
.iter()
|
|
.find(|b| b.kind == VerticalBandKind::Staff(staff_a))
|
|
.unwrap();
|
|
let band_b = staff_bands
|
|
.iter()
|
|
.find(|b| b.kind == VerticalBandKind::Staff(staff_b))
|
|
.unwrap();
|
|
assert_eq!(band_a.members.len(), 2);
|
|
assert_eq!(band_b.members.len(), 2);
|
|
let a_set: BTreeSet<_> = band_a.members.iter().collect();
|
|
assert!(
|
|
band_b.members.iter().all(|m| !a_set.contains(m)),
|
|
"no glyph is in both staves' bands"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_region_provenance_is_rejected_not_dropped() {
|
|
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
|
|
use crate::LayoutRegion;
|
|
use epiphany_core::EventId;
|
|
|
|
let logical = LogicalLayoutIR {
|
|
source: ScoreVersion::default(),
|
|
regions: vec![LayoutRegion {
|
|
provenance: Provenance::projected(
|
|
TypedObjectId::Event(EventId::from_raw(9)),
|
|
vec![],
|
|
),
|
|
coordinate_system: crate::LocalCoordinateSystem::default(),
|
|
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
|
|
vertical_extent: crate::VerticalExtent::default(),
|
|
objects: vec![],
|
|
}],
|
|
engraving_decisions: vec![],
|
|
overrides: vec![],
|
|
cross_region: vec![],
|
|
};
|
|
assert!(matches!(
|
|
try_to_constrained(&logical),
|
|
Err(LayoutTransformError::RegionSourceIsNotRegion(_))
|
|
));
|
|
}
|
|
}
|