mod fields;
use bevy_ecs::prelude::Component;
use ratatui_core::style::Color;
use crate::convert::{Canvas, FrameSources};
use fields::{EdgeFields, sobel_at};
const DEFAULT_THRESHOLD: f32 = 0.2;
const DEFAULT_DEPTH_THRESHOLD: f32 = 0.002;
const DIRECTION_BUCKET_DEGREES: f32 = 45.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EdgeCharacters {
pub horizontal: char,
pub vertical: char,
pub rising: char,
pub falling: char,
}
impl Default for EdgeCharacters {
fn default() -> Self {
Self {
horizontal: '―',
vertical: '|',
rising: '/',
falling: '\\',
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EdgeSource {
#[default]
Luminance,
Depth,
Both,
}
impl EdgeSource {
const fn reads_luminance(self) -> bool {
matches!(self, Self::Luminance | Self::Both)
}
const fn reads_depth(self) -> bool {
matches!(self, Self::Depth | Self::Both)
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct EdgeOverlay {
pub source: EdgeSource,
pub threshold: f32,
pub depth_threshold: f32,
pub characters: EdgeCharacters,
pub color: Option<Color>,
}
impl Default for EdgeOverlay {
fn default() -> Self {
Self {
source: EdgeSource::default(),
threshold: DEFAULT_THRESHOLD,
depth_threshold: DEFAULT_DEPTH_THRESHOLD,
characters: EdgeCharacters::default(),
color: None,
}
}
}
pub(crate) fn apply_edge_overlay(
overlay: &EdgeOverlay,
sources: &FrameSources<'_>,
canvas: &mut Canvas<'_>,
) {
let dest = canvas.dest;
if sources.size.x == 0 || sources.size.y == 0 || dest.width < 3 || dest.height < 3 {
return;
}
let fields = EdgeFields::resolve(overlay.source, sources, dest);
let scoring = Scoring::new(overlay);
for row in 1..dest.height - 1 {
for column in 1..dest.width - 1 {
let Some(direction) = edge_at(&fields, &scoring, dest.width, (column, row)) else {
continue;
};
let position = (dest.x + column, dest.y + row);
write_edge_cell(canvas.buffer, position, overlay, direction);
}
}
}
struct Scoring {
luminance: f32,
depth: f32,
}
impl Scoring {
fn new(overlay: &EdgeOverlay) -> Self {
Self {
luminance: 1.0 / (overlay.threshold * overlay.threshold),
depth: 1.0 / (overlay.depth_threshold * overlay.depth_threshold),
}
}
}
fn edge_at(
fields: &EdgeFields,
scoring: &Scoring,
width: u16,
cell: (u16, u16),
) -> Option<Direction> {
let scored = |values: &Option<Vec<f32>>, inverse: f32| {
let values = values.as_ref()?;
let (gx, gy) = sobel_at(values, width, cell);
Some((gx.mul_add(gx, gy * gy) * inverse, gx, gy))
};
let (score, gx, gy) = scored(&fields.luminance, scoring.luminance)
.into_iter()
.chain(scored(&fields.depth, scoring.depth))
.max_by(|left, right| left.0.total_cmp(&right.0))?;
(score > 1.0).then(|| direction(gx, gy))
}
fn direction(gx: f32, gy: f32) -> Direction {
let angle = gy.atan2(gx).to_degrees().rem_euclid(180.0);
let half_bucket = DIRECTION_BUCKET_DEGREES / 2.0;
match ((angle + half_bucket) / DIRECTION_BUCKET_DEGREES) as u32 % 4 {
0 => Direction::Vertical,
1 => Direction::Rising,
2 => Direction::Horizontal,
_ => Direction::Falling,
}
}
enum Direction {
Horizontal,
Vertical,
Rising,
Falling,
}
fn write_edge_cell(
buffer: &mut ratatui_core::buffer::Buffer,
position: (u16, u16),
overlay: &EdgeOverlay,
direction: Direction,
) {
let Some(cell) = buffer.cell_mut(position) else {
return;
};
cell.set_char(match direction {
Direction::Horizontal => overlay.characters.horizontal,
Direction::Vertical => overlay.characters.vertical,
Direction::Rising => overlay.characters.rising,
Direction::Falling => overlay.characters.falling,
});
if let Some(color) = overlay.color {
cell.set_fg(color);
}
}
#[cfg(test)]
mod tests;