use std::collections::HashMap;
use acorde_core::Duration;
use crate::glyphs;
const MIN_STEM_LEN_U: f32 = 2.0;
const MAX_BEAM_RISE_U: f32 = 1.0;
const BEAM_THICKNESS_U: f32 = 0.5;
const BEAM_LEVEL_GAP_U: f32 = 0.9;
const HOOK_LEN_U: f32 = 0.6;
fn beam_level(duration: &Duration) -> u8 {
match duration {
Duration::Eighth => 1,
Duration::Sixteenth => 2,
Duration::ThirtySecond => 3,
Duration::SixtyFourth => 4,
Duration::HundredTwentyEighth => 5,
_ => 0,
}
}
pub(crate) struct BeamPlan {
pub tips: HashMap<usize, f32>,
pub svg: String,
}
pub(crate) fn vertical_extents(
durations: &[Duration],
xs: &[f32],
attach_ys: &[f32],
stem_up: bool,
) -> (f32, f32) {
if durations.len() < 2 || xs.len() != durations.len() || attach_ys.len() != durations.len() {
return (0.0, 0.0);
}
let plan = plan_beam_group(durations, xs, attach_ys, stem_up, 1.0);
let mut min_y = plan.tips.values().copied().fold(f32::INFINITY, f32::min);
let mut max_y = plan
.tips
.values()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
let max_level = durations.iter().map(beam_level).max().unwrap_or(1);
let level_extent =
(max_level.saturating_sub(1) as f32 * BEAM_LEVEL_GAP_U) + BEAM_THICKNESS_U / 2.0;
if stem_up {
min_y -= BEAM_THICKNESS_U / 2.0;
max_y += level_extent;
} else {
min_y -= level_extent;
max_y += BEAM_THICKNESS_U / 2.0;
}
(min_y, max_y)
}
pub(crate) fn plan_beam_group(
durations: &[Duration],
xs: &[f32],
attach_ys: &[f32],
stem_up: bool,
space: f32,
) -> BeamPlan {
let n = durations.len();
debug_assert_eq!(xs.len(), n);
debug_assert_eq!(attach_ys.len(), n);
let dir = if stem_up { -1.0 } else { 1.0 };
let mut svg = String::new();
let stem_offset = glyphs::NOTEHEAD_RX_U * space * 0.92 * -dir;
let stem_xs: Vec<f32> = xs.iter().map(|x| x + stem_offset).collect();
let xs = stem_xs.as_slice();
let half_stem = glyphs::STEM_WIDTH_U * space / 2.0;
let natural = |i: usize| attach_ys[i] + dir * glyphs::DEFAULT_STEM_LEN_U * space;
let raw_rise = natural(n - 1) - natural(0);
let clamped_rise = raw_rise.clamp(-MAX_BEAM_RISE_U * space, MAX_BEAM_RISE_U * space);
let dx = xs[n - 1] - xs[0];
let slope = if dx.abs() > 1e-6 {
clamped_rise / dx
} else {
0.0
};
let base_y = |x: f32| natural(0) + slope * (x - xs[0]);
let levels = durations.iter().map(beam_level).max().unwrap_or(1).max(1);
let min_stem = (MIN_STEM_LEN_U + f32::from(levels - 1) * BEAM_LEVEL_GAP_U) * space;
let mut max_deficit = 0.0f32;
for (&x, &attach_y) in xs.iter().zip(attach_ys) {
let stem_len = (base_y(x) - attach_y).abs();
let deficit = min_stem - stem_len;
if deficit > max_deficit {
max_deficit = deficit;
}
}
let beam_y = |x: f32| base_y(x) + dir * max_deficit;
let mut tips = HashMap::with_capacity(n);
for (i, &x) in xs.iter().enumerate() {
tips.insert(i, beam_y(x));
}
svg.push_str(&glyphs::beam_segment(
xs[0] - half_stem,
beam_y(xs[0] - half_stem),
xs[n - 1] + half_stem,
beam_y(xs[n - 1] + half_stem),
BEAM_THICKNESS_U * space,
));
let max_level = durations.iter().map(beam_level).max().unwrap_or(1);
for level in 1..max_level {
let needs: Vec<bool> = durations.iter().map(|d| beam_level(d) > level).collect();
let level_y = |x: f32| beam_y(x) - dir * level as f32 * BEAM_LEVEL_GAP_U * space;
let mut i = 0;
while i < n {
if !needs[i] {
i += 1;
continue;
}
let run_start = i;
while i < n && needs[i] {
i += 1;
}
let run_end = i - 1;
if run_start == run_end {
let hook_dir = if run_start > 0 { -1.0 } else { 1.0 };
let x0 = xs[run_start];
let x1 = x0 + hook_dir * HOOK_LEN_U * space;
svg.push_str(&glyphs::beam_segment(
x0,
level_y(x0),
x1,
level_y(x1),
BEAM_THICKNESS_U * space,
));
} else {
svg.push_str(&glyphs::beam_segment(
xs[run_start],
level_y(xs[run_start]),
xs[run_end],
level_y(xs[run_end]),
BEAM_THICKNESS_U * space,
));
}
}
}
BeamPlan { tips, svg }
}
pub(crate) fn plan_kneed_beam_group(
durations: &[Duration],
xs: &[f32],
attach_ys: &[f32],
stem_ups: &[bool],
space: f32,
) -> BeamPlan {
let n = durations.len();
debug_assert_eq!(xs.len(), n);
debug_assert_eq!(attach_ys.len(), n);
debug_assert_eq!(stem_ups.len(), n);
let stem_xs: Vec<f32> = xs
.iter()
.zip(stem_ups)
.map(|(&x, &up)| x + glyphs::NOTEHEAD_RX_U * space * 0.92 * if up { 1.0 } else { -1.0 })
.collect();
let half_stem = glyphs::STEM_WIDTH_U * space / 2.0;
let levels = durations.iter().map(beam_level).max().unwrap_or(1).max(1);
let min_stem = MIN_STEM_LEN_U * space;
let stack = f32::from(levels - 1) * BEAM_LEVEL_GAP_U * space;
let lowest = attach_ys
.iter()
.zip(stem_ups)
.filter(|&(_, &up)| !up)
.map(|(&y, _)| y + min_stem)
.fold(f32::NEG_INFINITY, f32::max);
let highest = attach_ys
.iter()
.zip(stem_ups)
.filter(|&(_, &up)| up)
.map(|(&y, _)| y - min_stem)
.fold(f32::INFINITY, f32::min);
let beam_y = match (lowest.is_finite(), highest.is_finite()) {
(true, true) => (lowest + highest) / 2.0,
(true, false) => lowest + stack,
(false, true) => highest - stack,
(false, false) => attach_ys.first().copied().unwrap_or(0.0),
};
let mut tips: HashMap<usize, f32> = (0..n).map(|i| (i, beam_y)).collect();
let mut svg = glyphs::beam_segment(
stem_xs[0] - half_stem,
beam_y,
stem_xs[n - 1] + half_stem,
beam_y,
BEAM_THICKNESS_U * space,
);
let max_level = durations.iter().map(beam_level).max().unwrap_or(1);
for level in 1..max_level {
let needs: Vec<bool> = durations.iter().map(|d| beam_level(d) > level).collect();
let mut i = 0;
while i < n {
if !needs[i] {
i += 1;
continue;
}
let run_start = i;
while i < n && needs[i] {
i += 1;
}
let run_end = i - 1;
let side = if stem_ups[run_start] { 1.0 } else { -1.0 };
let y = beam_y + side * level as f32 * BEAM_LEVEL_GAP_U * space;
for (index, &up) in stem_ups
.iter()
.enumerate()
.take(run_end + 1)
.skip(run_start)
{
let tip = tips.entry(index).or_insert(beam_y);
*tip = if up { tip.min(y) } else { tip.max(y) };
}
let (x0, x1) = if run_start == run_end {
let hook_dir = if run_start > 0 { -1.0 } else { 1.0 };
let x0 = stem_xs[run_start];
(x0, x0 + hook_dir * HOOK_LEN_U * space)
} else {
(stem_xs[run_start], stem_xs[run_end])
};
svg.push_str(&glyphs::beam_segment(
x0,
y,
x1,
y,
BEAM_THICKNESS_U * space,
));
}
}
BeamPlan { tips, svg }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kneed_beam_lies_between_down_and_up_stemmed_heads() {
let durations = vec![Duration::Eighth, Duration::Sixteenth, Duration::Sixteenth];
let xs = vec![0.0, 20.0, 40.0];
let attach_ys = vec![100.0, 90.0, 400.0];
let plan = plan_kneed_beam_group(&durations, &xs, &attach_ys, &[false, false, true], 20.0);
let primary = plan.tips[&0];
assert!(primary > 100.0 + MIN_STEM_LEN_U * 20.0);
assert!(primary < 400.0 - MIN_STEM_LEN_U * 20.0);
assert!(plan.tips[&2] < primary);
assert!((plan.tips[&1] - primary).abs() < 0.01);
}
#[test]
fn two_note_flat_beam_gives_default_stem_length() {
let durations = vec![Duration::Eighth, Duration::Eighth];
let xs = vec![0.0, 20.0];
let attach_ys = vec![100.0, 100.0];
let plan = plan_beam_group(&durations, &xs, &attach_ys, true, 20.0);
assert_eq!(plan.tips.len(), 2);
for &tip in plan.tips.values() {
assert!((tip - (100.0 - glyphs::DEFAULT_STEM_LEN_U * 20.0)).abs() < 0.01);
}
}
#[test]
fn steep_pitch_span_clamps_beam_rise() {
let durations = vec![Duration::Eighth, Duration::Eighth];
let xs = vec![0.0, 20.0];
let space = 20.0;
let attach_ys = vec![100.0, 100.0 - 10.0 * space]; let plan = plan_beam_group(&durations, &xs, &attach_ys, true, space);
let tip0 = plan.tips[&0];
let tip1 = plan.tips[&1];
assert!(
(tip0 - tip1).abs() <= MAX_BEAM_RISE_U * space + 0.01,
"beam rise must be clamped to {MAX_BEAM_RISE_U} staff-spaces, got {}",
(tip0 - tip1).abs()
);
}
#[test]
fn interior_note_never_gets_a_too_short_stem() {
let durations = vec![Duration::Eighth, Duration::Eighth, Duration::Eighth];
let xs = vec![0.0, 20.0, 40.0];
let space = 20.0;
let attach_ys = vec![
100.0,
100.0 - glyphs::DEFAULT_STEM_LEN_U * space + 5.0,
100.0,
];
let plan = plan_beam_group(&durations, &xs, &attach_ys, true, space);
for (i, &attach_y) in attach_ys.iter().enumerate() {
let stem_len = (plan.tips[&i] - attach_y).abs();
assert!(
stem_len >= MIN_STEM_LEN_U * space - 0.01,
"note {i} stem length {stem_len} is below the {MIN_STEM_LEN_U}-space minimum"
);
}
}
#[test]
fn sixteenth_notes_get_a_second_beam_level_spanning_the_whole_group() {
let durations = vec![Duration::Sixteenth, Duration::Sixteenth];
let xs = vec![0.0, 20.0];
let attach_ys = vec![100.0, 100.0];
let plan = plan_beam_group(&durations, &xs, &attach_ys, true, 20.0);
assert_eq!(plan.svg.matches("acorde-beam").count(), 2);
}
#[test]
fn isolated_sixteenth_among_eighths_gets_a_hook_not_a_full_run() {
let durations = vec![Duration::Sixteenth, Duration::Eighth, Duration::Eighth];
let xs = vec![0.0, 20.0, 40.0];
let attach_ys = vec![100.0, 100.0, 100.0];
let plan = plan_beam_group(&durations, &xs, &attach_ys, true, 20.0);
assert_eq!(plan.svg.matches("acorde-beam").count(), 2);
}
#[test]
fn stem_down_beam_sits_below_noteheads() {
let durations = vec![Duration::Eighth, Duration::Eighth];
let xs = vec![0.0, 20.0];
let attach_ys = vec![100.0, 100.0];
let plan = plan_beam_group(&durations, &xs, &attach_ys, false, 20.0);
for &tip in plan.tips.values() {
assert!(
tip > 100.0,
"stem-down beam tips must be below (larger y than) the notehead"
);
}
}
#[test]
fn sloped_beam_bounds_include_clearance_shift_and_secondary_levels() {
let durations = vec![
Duration::Sixteenth,
Duration::Sixteenth,
Duration::Sixteenth,
];
let xs = vec![0.0, 0.5, 1.0];
let attach_ys = vec![0.0, -1.0, 0.0];
let (min_y, max_y) = vertical_extents(&durations, &xs, &attach_ys, true);
assert!(
min_y < -3.0,
"top bound must include secondary beams: {min_y}"
);
assert!(
max_y < -1.5,
"the inner (secondary) beam must clear the highest notehead: {max_y}"
);
let (min_down, max_down) = vertical_extents(&durations, &xs, &attach_ys, false);
assert!(
min_down > 1.5,
"the inner (secondary) beam must clear the lowest notehead: {min_down}"
);
assert!(
max_down > 3.0,
"bottom bound must include secondary beams: {max_down}"
);
}
}