epiphany/crates/epiphany-engrave/src/spacing.rs

189 lines
7.9 KiB
Rust

//! The horizontal spacing pass — the first axis of the planned two-pass spring
//! layout (`epiphany-engrave`'s DECISIONS.md, decision 1).
//!
//! A `ConstrainedLayoutIR` carries horizontal spring slots — one slot per
//! *musical time column* (`to_constrained` groups simultaneous glyphs into a
//! shared column slot, with the clef in a lead column and barlines in their own
//! columns). This pass places each glyph-bearing slot left to right and yields
//! the coordinate-map control points the caller ([`crate::HorizontalRemap`])
//! applies to glyph baselines *and* the strokes that track them.
//!
//! The advance from one slot to the next is the larger of the slot's
//! `preferred_width` (the spring's natural width — a uniform placeholder in v0)
//! and a **collision minimum** derived from real glyph bounding boxes: the slot's
//! right content extent, plus a gap, plus the *next* slot's left overhang (its
//! accidental zone). Reserving the next slot's left overhang against *this* slot's
//! advance is what protects a note's accidental from overlapping the previous
//! note — a single per-slot `preferred_width` could only reserve space to the
//! right of a slot's source. The casting-off pass (`crate::casting`) then breaks
//! this spaced line into systems and pages; the vertical soft-spring
//! stretch/compress solve and per-system justification remain deferred (see
//! `DECISIONS.md`).
use std::collections::BTreeMap;
use epiphany_layout_ir::{is_rigid_width_stroke, ConstrainedLayoutIR, SpringSlotId};
use crate::owning_glyph;
/// Inter-slot gap (staff spaces) reserved between one slot's right content and
/// the next slot's left content.
const SLOT_GAP: f32 = 0.3;
/// The spacing pass's output: the interpolation control points for spanning
/// strokes, and each glyph-bearing slot's exact `(source, target)` pair — the
/// rigid delta every member glyph translates by, so intra-slot offsets (a
/// time signature after its barline, key-signature accidentals after the
/// clef, an accidental left of its notehead) survive the re-spacing verbatim.
pub(crate) struct SpacedSlots {
/// `(source_x, target_x)` control points, sorted by source, sources
/// distinct — the piecewise-linear map for content that genuinely *spans*
/// columns (staff lines, brackets).
pub points: Vec<(f32, f32)>,
/// Each glyph-bearing slot's own `(source_x, target_x)`.
pub by_slot: BTreeMap<SpringSlotId, (f32, f32)>,
}
/// Spaces the glyph-bearing slots left to right. Each slot's source is its
/// column reference (its first member glyph's baseline); the target
/// accumulates collision-aware advances so neighbouring slots' content —
/// including left-overhanging accidentals — never overlaps, and a wide lead
/// (clef + key signature) reserves real space. Deterministic: a pure function
/// of the glyphs and their bounding boxes.
pub(crate) fn space_slots(input: &ConstrainedLayoutIR) -> SpacedSlots {
/// One slot's horizontal extent, from its member glyphs.
struct Extent {
/// Column reference x (the first member's baseline).
source: f32,
/// Leftmost / rightmost content edge across the slot's glyphs.
min_left: f32,
max_right: f32,
/// The spring's natural width.
preferred: f32,
}
let preferred_of: BTreeMap<SpringSlotId, f32> = input
.horizontal_slots
.iter()
.map(|s| (s.id, s.preferred_width.0))
.collect();
let mut by_slot: BTreeMap<SpringSlotId, Extent> = BTreeMap::new();
for glyph in &input.glyphs {
let left = glyph.baseline.x.0 + glyph.bounding_box.left.0;
let right = glyph.baseline.x.0 + glyph.bounding_box.right.0;
by_slot
.entry(glyph.horizontal_slot)
.and_modify(|e| {
e.min_left = e.min_left.min(left);
e.max_right = e.max_right.max(right);
})
.or_insert(Extent {
source: glyph.baseline.x.0,
min_left: left,
max_right: right,
preferred: preferred_of
.get(&glyph.horizontal_slot)
.copied()
.unwrap_or(0.0),
});
}
// Fold each ledger line (a fixed-width stroke) into its notehead's slot extent,
// so a ledger that overhangs the notehead reserves room — otherwise adjacent
// off-staff notes' ledgers can overlap even though glyph spacing is collision-
// aware. The owning notehead is the same-source glyph whose baseline lies within
// the stroke's span (its accidentals sit outside it, to the left).
for stroke in &input.strokes {
if !is_rigid_width_stroke(stroke) {
continue;
}
if let Some(glyph) = owning_glyph(stroke, &input.glyphs) {
let lo = stroke.from.x.0.min(stroke.to.x.0);
let hi = stroke.from.x.0.max(stroke.to.x.0);
if let Some(extent) = by_slot.get_mut(&glyph.horizontal_slot) {
extent.min_left = extent.min_left.min(lo);
extent.max_right = extent.max_right.max(hi);
}
}
}
let mut slots: Vec<(SpringSlotId, Extent)> = by_slot.into_iter().collect();
slots.sort_by(|a, b| {
a.1.source
.partial_cmp(&b.1.source)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut points = Vec::with_capacity(slots.len());
let mut placed: BTreeMap<SpringSlotId, (f32, f32)> = BTreeMap::new();
let mut target = 0.0_f32;
for i in 0..slots.len() {
let (id, extent) = &slots[i];
points.push((extent.source, target));
placed.insert(*id, (extent.source, target));
let right_bearing = extent.max_right - extent.source;
// The next slot's left overhang must be cleared by *this* slot's advance.
let next_left = slots
.get(i + 1)
.map(|(_, next)| next.source - next.min_left)
.unwrap_or(0.0);
let advance = extent.preferred.max(right_bearing + SLOT_GAP + next_left);
target += advance;
}
points.dedup_by(|a, b| a.0 == b.0);
SpacedSlots {
points,
by_slot: placed,
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::generators::valid_score_rich;
use epiphany_layout_ir::{to_constrained, to_logical};
#[test]
fn control_points_are_monotonic_in_source_and_target() {
let c = to_constrained(&to_logical(&valid_score_rich(7)));
let spaced = space_slots(&c);
assert!(!spaced.points.is_empty());
for w in spaced.points.windows(2) {
assert!(w[1].0 > w[0].0, "sources strictly increase");
assert!(w[1].1 > w[0].1, "targets strictly increase");
}
// The two views describe one spacing: every placed slot's pair is one
// of the control points (this fixture's slot sources are all distinct,
// so the equal-source dedup removes nothing).
for (source, target) in spaced.by_slot.values() {
assert!(
spaced
.points
.iter()
.any(|(s, t)| s == source && t == target),
"slot pair ({source}, {target}) must be a control point"
);
}
}
#[test]
fn spacing_re_spaces_rather_than_echoing_sources() {
// A wide lead (clef) advances by more than a uniform note slot, so the
// engraved targets are not a copy of the source columns.
let c = to_constrained(&to_logical(&valid_score_rich(7)));
let spaced = space_slots(&c);
assert!(
spaced.points.iter().any(|(s, t)| (s - t).abs() > 1e-3),
"targets must differ from sources (re-spacing happened)"
);
}
#[test]
fn spacing_is_deterministic() {
let c = to_constrained(&to_logical(&valid_score_rich(3)));
let (a, b) = (space_slots(&c), space_slots(&c));
assert_eq!(a.points, b.points);
assert_eq!(a.by_slot, b.by_slot);
}
}