trailgen-gui 0.3.5

Native Brass Poolrooms workbench for constrained trail-loop generation.
Documentation
use egui::{Color32, Pos2, Shape, Stroke};

const WORLD_LEVEL_OFFSET: u8 = 6;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WorldLevel(u8);

impl WorldLevel {
    pub fn resolve(prior: Option<Self>, zoom: f64, hysteresis: f64) -> Self {
        let target = Self::at_zoom(zoom);
        let Some(prior) = prior else {
            return target;
        };
        let boundary = f64::from(prior.0.saturating_sub(WORLD_LEVEL_OFFSET));
        let retain = (target.0 > prior.0 && zoom < boundary + 1.0 + hysteresis)
            || (target.0 < prior.0 && zoom >= boundary - hysteresis);
        if retain { prior } else { target }
    }

    pub const fn at_zoom(zoom: f64) -> Self {
        Self((zoom.floor() as u8).saturating_add(WORLD_LEVEL_OFFSET))
    }

    pub fn cells_per_world(self) -> f64 {
        2.0_f64.powi(i32::from(self.0))
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pattern {
    Dash { dash: f32, gap: f32 },
    DashDot { dash: f32, gap: f32, dot: f32 },
    Dots { spacing: f32, radius: f32 },
}

impl Pattern {
    pub fn tessellate<I>(
        self,
        points: I,
        stroke: Stroke,
        phase: f32,
        limit: f32,
        shapes: &mut Vec<Shape>,
    ) where
        I: IntoIterator<Item = Pos2>,
    {
        match self {
            Self::Dash { dash, gap } => {
                stroke_pattern(points, stroke, &[dash, gap], phase, limit, shapes);
            }
            Self::DashDot { dash, gap, dot } => {
                stroke_pattern(points, stroke, &[dash, gap, dot, gap], phase, limit, shapes);
            }
            Self::Dots { spacing, radius } => {
                dot_pattern(points, stroke.color, spacing, radius, phase, limit, shapes);
            }
        }
    }
}

pub fn polyline_length(points: &[Pos2]) -> f32 {
    points
        .windows(2)
        .map(|window| window[0].distance(window[1]))
        .sum()
}

#[expect(
    clippy::while_float,
    reason = "the arc-length cursor advances by a strictly positive hatch spacing"
)]
pub fn crossbars(
    points: &[Pos2],
    stroke: Stroke,
    span: f32,
    spacing: f32,
    phase: f32,
    shapes: &mut Vec<Shape>,
) {
    debug_assert!(span.is_finite() && span > 0.0);
    debug_assert!(spacing.is_finite() && spacing > 0.0);
    let phase = phase.rem_euclid(spacing);
    let mut next = if phase <= f32::EPSILON {
        0.0
    } else {
        spacing - phase
    };
    let mut traversed = 0.0;
    for segment in points.windows(2) {
        let [start, end] = [segment[0], segment[1]];
        let vector = end - start;
        let length = vector.length();
        if length <= f32::EPSILON {
            continue;
        }
        let normal = egui::vec2(-vector.y, vector.x) * (span * 0.5 / length);
        while next <= traversed + length {
            let center = start + vector * ((next - traversed) / length);
            shapes.push(Shape::line_segment(
                [center - normal, center + normal],
                stroke,
            ));
            next += spacing;
        }
        traversed += length;
    }
}

#[expect(
    clippy::while_float,
    reason = "the arc-length cursor advances by a strictly positive cadence span"
)]
fn stroke_pattern<I>(
    points: I,
    stroke: Stroke,
    spans: &[f32],
    phase: f32,
    limit: f32,
    shapes: &mut Vec<Shape>,
) where
    I: IntoIterator<Item = Pos2>,
{
    debug_assert!(
        !spans.is_empty()
            && spans.len().is_multiple_of(2)
            && spans.iter().all(|span| span.is_finite() && *span > 0.0)
    );
    let period = spans.iter().sum::<f32>();
    let mut phase = phase.rem_euclid(period);
    let mut step = 0;
    for (candidate, span) in spans.iter().copied().enumerate() {
        if phase < span {
            step = candidate;
            break;
        }
        phase -= span;
    }
    let mut remaining = spans[step] - phase;
    let mut ink = step.is_multiple_of(2);
    let mut stroke_points = Vec::new();
    let mut points = points.into_iter();
    let Some(mut start) = points.next() else {
        return;
    };
    if ink {
        stroke_points.push(start);
    }
    let mut budget = limit;

    for end in points {
        if budget <= f32::EPSILON {
            break;
        }
        let vector = end - start;
        let segment_length = vector.length();
        if segment_length <= f32::EPSILON {
            start = end;
            continue;
        }
        let traversable = segment_length.min(budget);
        let mut cursor = 0.0;
        while remaining <= traversable - cursor {
            cursor += remaining;
            let cut = start + vector * (cursor / segment_length);
            if ink {
                stroke_points.push(cut);
                emit_stroke(&mut stroke_points, stroke, shapes);
            }
            step = (step + 1) % spans.len();
            ink = step.is_multiple_of(2);
            remaining = spans[step];
            if ink {
                stroke_points.push(cut);
            }
        }
        let residue = traversable - cursor;
        if residue > f32::EPSILON {
            let cut = start + vector * (traversable / segment_length);
            if ink {
                stroke_points.push(cut);
            }
            remaining -= residue;
        }
        budget -= traversable;
        start = end;
    }
    if ink {
        emit_stroke(&mut stroke_points, stroke, shapes);
    }
}

fn emit_stroke(points: &mut Vec<Pos2>, stroke: Stroke, shapes: &mut Vec<Shape>) {
    if points.len() >= 2 {
        shapes.push(Shape::line(std::mem::take(points), stroke));
    } else {
        points.clear();
    }
}

#[expect(
    clippy::while_float,
    reason = "the arc-length cursor advances by a strictly positive dot spacing"
)]
fn dot_pattern<I>(
    points: I,
    color: Color32,
    spacing: f32,
    radius: f32,
    phase: f32,
    limit: f32,
    shapes: &mut Vec<Shape>,
) where
    I: IntoIterator<Item = Pos2>,
{
    debug_assert!(spacing.is_finite() && spacing > 0.0);
    let phase = phase.rem_euclid(spacing);
    let mut next = if phase <= f32::EPSILON {
        0.0
    } else {
        spacing - phase
    };
    let mut traversed = 0.0;
    let mut points = points.into_iter();
    let Some(mut start) = points.next() else {
        return;
    };

    for end in points {
        if traversed >= limit {
            break;
        }
        let vector = end - start;
        let segment_length = vector.length();
        if segment_length <= f32::EPSILON {
            start = end;
            continue;
        }
        let traversable = segment_length.min(limit - traversed);
        while next <= traversed + traversable {
            let progress = (next - traversed) / segment_length;
            shapes.push(Shape::circle_filled(
                start + vector * progress,
                radius,
                color,
            ));
            next += spacing;
        }
        traversed += traversable;
        start = end;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use egui::pos2;

    #[test]
    fn primitive_boundaries_do_not_reset_dash_phase() {
        let pattern = Pattern::Dash {
            dash: 6.0,
            gap: 4.0,
        };
        let stroke = Stroke::new(1.0_f32, Color32::BLACK);
        let first = [pos2(0.0, 0.0), pos2(13.0, 0.0)];
        let second = [pos2(13.0, 0.0), pos2(26.0, 0.0)];
        let whole = [pos2(0.0, 0.0), pos2(13.0, 0.0), pos2(26.0, 0.0)];
        let mut split_shapes = Vec::new();
        pattern.tessellate(first, stroke, 0.0, f32::INFINITY, &mut split_shapes);
        pattern.tessellate(second, stroke, 13.0, f32::INFINITY, &mut split_shapes);
        let mut whole_shapes = Vec::new();
        pattern.tessellate(whole, stroke, 0.0, f32::INFINITY, &mut whole_shapes);

        assert_eq!(ink_bounds(&split_shapes), ink_bounds(&whole_shapes));
    }

    fn ink_bounds(shapes: &[Shape]) -> Vec<(i32, i32)> {
        shapes
            .iter()
            .map(Shape::visual_bounding_rect)
            .map(|bounds| {
                (
                    (bounds.left() * 1_000.0).round() as i32,
                    (bounds.right() * 1_000.0).round() as i32,
                )
            })
            .fold(Vec::new(), |mut union, next| {
                if let Some(previous) = union.last_mut()
                    && next.0 <= previous.1
                {
                    previous.1 = previous.1.max(next.1);
                } else {
                    union.push(next);
                }
                union
            })
    }
}