use crate::core::{Color, Point, Rect};
use crate::render::RenderContext;
pub const BEVEL_WEIGHT: f32 = 0.5;
pub const BEVEL_INNER_WEIGHT: f32 = 0.25;
pub const BEVEL_INNER_SHADE_WEIGHT: f32 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BevelDirection {
#[default]
Raised,
Inset,
}
impl BevelDirection {
pub fn as_str(self) -> &'static str {
match self {
BevelDirection::Raised => "raised",
BevelDirection::Inset => "inset",
}
}
pub fn parse(token: &str) -> Option<Self> {
token.parse().ok()
}
}
impl core::str::FromStr for BevelDirection {
type Err = ();
fn from_str(token: &str) -> Result<Self, Self::Err> {
match token {
"raised" => Ok(BevelDirection::Raised),
"inset" => Ok(BevelDirection::Inset),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bevel {
pub light: Color,
pub shade: Color,
pub base: Color,
pub direction: BevelDirection,
}
impl Bevel {
pub fn from_base(base: Color) -> Self {
Self {
light: base.blend(&Color::WHITE, BEVEL_WEIGHT),
shade: base.blend(&Color::BLACK, BEVEL_WEIGHT),
base,
direction: BevelDirection::default(),
}
}
pub fn from_tones(base: Color, light: Color, shade: Color) -> Self {
Self { light, shade, base, direction: BevelDirection::default() }
}
pub fn with_direction(mut self, direction: BevelDirection) -> Self {
self.direction = direction;
self
}
pub fn leading_tone(&self) -> Color {
match self.direction {
BevelDirection::Raised => self.light,
BevelDirection::Inset => self.shade,
}
}
pub fn trailing_tone(&self) -> Color {
match self.direction {
BevelDirection::Raised => self.shade,
BevelDirection::Inset => self.light,
}
}
pub fn inner_tones(&self) -> (Color, Color) {
let (light, shade) = (
self.base.blend(&Color::WHITE, BEVEL_INNER_WEIGHT),
self.base.blend(&Color::BLACK, BEVEL_INNER_SHADE_WEIGHT),
);
match self.direction {
BevelDirection::Raised => (light, shade),
BevelDirection::Inset => (shade, light),
}
}
pub fn stroke(&self, context: &mut RenderContext, rect: Rect, width: u32) {
let (leading, trailing) = (self.leading_tone(), self.trailing_tone());
let (x0, y0) = (rect.x as f32, rect.y as f32);
let (x1, y1) = (rect.x as f32 + rect.width as f32, rect.y as f32 + rect.height as f32);
self.edge(context, Point::from_f32(x0, y0), Point::from_f32(x1, y0), leading, width);
self.edge(context, Point::from_f32(x0, y0), Point::from_f32(x0, y1), leading, width);
self.edge(context, Point::from_f32(x0, y1), Point::from_f32(x1, y1), trailing, width);
self.edge(context, Point::from_f32(x1, y0), Point::from_f32(x1, y1), trailing, width);
}
pub fn stroke_inner(&self, context: &mut RenderContext, rect: Rect, width: u32) {
let inset = width as i32;
let inner = Rect::new(
rect.x + inset,
rect.y + inset,
rect.width.saturating_sub(width * 2),
rect.height.saturating_sub(width * 2),
);
let (leading, trailing) = self.inner_tones();
let (x0, y0) = (inner.x as f32, inner.y as f32);
let (x1, y1) = (inner.x as f32 + inner.width as f32, inner.y as f32 + inner.height as f32);
self.edge(context, Point::from_f32(x0, y0), Point::from_f32(x1, y0), leading, width);
self.edge(context, Point::from_f32(x0, y0), Point::from_f32(x0, y1), leading, width);
self.edge(context, Point::from_f32(x0, y1), Point::from_f32(x1, y1), trailing, width);
self.edge(context, Point::from_f32(x1, y0), Point::from_f32(x1, y1), trailing, width);
}
fn edge(&self, context: &mut RenderContext, from: Point, to: Point, color: Color, width: u32) {
context.draw_line_stroke(from, to, color, width);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Size;
use crate::render::{PaintBackend, SoftwarePaintBackend};
fn painted<F>(size: Size, f: F) -> Vec<(i32, i32, Color)>
where
F: FnOnce(&mut RenderContext),
{
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::rgba(0, 0, 0, 0));
{
let mut context = RenderContext::new(&mut backend);
f(&mut context);
}
backend.end_frame();
let rgba = backend.frame_rgba();
let mut out = Vec::new();
for y in 0..size.height as i32 {
for x in 0..size.width as i32 {
let at = ((y as u32 * size.width + x as u32) * 4) as usize;
let px = Color::rgba(rgba[at], rgba[at + 1], rgba[at + 2], rgba[at + 3]);
if px.a != 0 {
out.push((x, y, px));
}
}
}
out
}
const RECT: Rect = Rect { x: 4, y: 4, width: 20, height: 10 };
const SURFACE: Size = Size { width: 28, height: 18 };
fn at(px: &[(i32, i32, Color)], x: i32, y: i32) -> Color {
px.iter()
.find(|(px, py, _)| *px == x && *py == y)
.unwrap_or_else(|| panic!("no paint at {x},{y}"))
.2
}
#[test]
fn a_raised_bevel_lights_the_top_and_shades_the_bottom() {
let bevel = Bevel::from_base(Color::rgb(128, 128, 128));
let px = painted(SURFACE, |c| bevel.stroke(c, RECT, 1));
let mid_x = RECT.x + RECT.width as i32 / 2;
let top = at(&px, mid_x, RECT.y);
let bottom = at(&px, mid_x, RECT.y + RECT.height as i32);
assert!(
top.luminance() > bottom.luminance(),
"the top must be lighter than the bottom: top {top:?} vs bottom {bottom:?}"
);
}
#[test]
fn an_inset_is_a_raised_bevel_with_its_edges_exchanged() {
let raised = Bevel::from_base(Color::rgb(128, 128, 128));
let inset = raised.with_direction(BevelDirection::Inset);
assert_eq!(raised.light, inset.light, "the tones are the same");
assert_eq!(raised.shade, inset.shade, "the tones are the same");
assert_eq!(raised.leading_tone(), inset.trailing_tone());
assert_eq!(raised.trailing_tone(), inset.leading_tone());
let px = painted(SURFACE, |c| inset.stroke(c, RECT, 1));
let mid_x = RECT.x + RECT.width as i32 / 2;
let top = at(&px, mid_x, RECT.y);
let bottom = at(&px, mid_x, RECT.y + RECT.height as i32);
assert!(
top.luminance() < bottom.luminance(),
"an inset must invert the pair: top {top:?} vs bottom {bottom:?}"
);
}
#[test]
fn the_inner_pair_follows_the_outer_pair() {
for direction in [BevelDirection::Raised, BevelDirection::Inset] {
let base = Color::rgb(128, 128, 128);
let bevel = Bevel::from_base(base).with_direction(direction);
let (inner_leading, inner_trailing) = bevel.inner_tones();
let (outer_lit, outer_shaded) = (bevel.light.luminance(), bevel.shade.luminance());
let side = |c: Color| c.luminance() - base.luminance();
assert_eq!(
side(inner_leading).signum(),
side(bevel.leading_tone()).signum(),
"{direction:?}: the inner leading line must be on the same side of the base as the \
outer leading line"
);
assert_eq!(
side(inner_trailing).signum(),
side(bevel.trailing_tone()).signum(),
"{direction:?}: and the inner trailing line likewise"
);
assert!(
side(inner_leading).abs() < side(bevel.leading_tone()).abs(),
"{direction:?}: the inner highlight must be softer than the outer one"
);
assert!(
side(inner_trailing).abs() < side(bevel.trailing_tone()).abs(),
"{direction:?}: the inner shade must be softer than the outer one"
);
assert!(outer_lit > base.luminance() && outer_shaded < base.luminance());
}
}
#[test]
fn the_tones_are_derived_from_the_base_colour() {
let dark = Bevel::from_base(Color::rgb(30, 30, 33));
let light = Bevel::from_base(Color::rgb(240, 240, 240));
assert_ne!(dark.light, light.light, "the highlight must follow the base");
assert_ne!(dark.shade, light.shade, "the shade must follow the base");
for bevel in [dark, light] {
assert!(bevel.light.luminance() > bevel.shade.luminance());
}
}
#[test]
fn the_stroke_width_applies_to_every_edge() {
for width in [1u32, 2, 3] {
let bevel = Bevel::from_base(Color::rgb(128, 128, 128));
let px = painted(SURFACE, |c| bevel.stroke(c, RECT, width));
let mid_x = RECT.x + RECT.width as i32 / 2;
let first = RECT.y - width as i32 / 2;
for dy in 0..width as i32 {
let y = first + dy;
let c = at(&px, mid_x, y);
assert!(
c.luminance() > 128.0 / 255.0,
"a {width}px bevel must light row {y} of the top edge (row {dy} of the brush), \
but it is {c:?}"
);
}
for y in [first - 1, first + width as i32] {
assert!(
!px.iter().any(|(x, py, _)| *x == mid_x && *py == y),
"a {width}px bevel must not reach row {y}"
);
}
}
}
#[test]
fn a_face_too_small_for_its_inner_lines_degrades_rather_than_panics() {
let bevel = Bevel::from_base(Color::rgb(128, 128, 128));
let tiny = Rect::new(0, 0, 2, 2);
let px = painted(Size::new(4, 4), |c| {
bevel.stroke(c, tiny, 4);
bevel.stroke_inner(c, tiny, 4);
});
assert!(!px.is_empty(), "a saturated inner rect must still paint the outer bevel");
}
#[test]
fn the_direction_tokens_round_trip_and_reject_unknown() {
assert_eq!(BevelDirection::parse("raised"), Some(BevelDirection::Raised));
assert_eq!(BevelDirection::parse("inset"), Some(BevelDirection::Inset));
assert_eq!(BevelDirection::parse("sunken"), None, "not a token this crate publishes");
assert_eq!(BevelDirection::parse(""), None);
assert_eq!(BevelDirection::parse("Raised"), None, "tokens are lower-case");
for direction in [BevelDirection::Raised, BevelDirection::Inset] {
assert_eq!(BevelDirection::parse(direction.as_str()), Some(direction));
}
assert_eq!("inset".parse::<BevelDirection>(), Ok(BevelDirection::Inset));
assert_eq!("nope".parse::<BevelDirection>(), Err(()));
}
#[test]
fn every_tone_is_a_step_toward_one_extreme_on_every_channel() {
let base = Color::rgb(120, 40, 200);
let bevel = Bevel::from_base(base);
let (inner_light, inner_shade) = bevel.inner_tones();
let tones = [
("light", bevel.light, true),
("shade", bevel.shade, false),
("inner light", inner_light, true),
("inner shade", inner_shade, false),
];
for (name, tone, toward_white) in tones {
for (channel, tone_c, base_c) in
[("r", tone.r, base.r), ("g", tone.g, base.g), ("b", tone.b, base.b)]
{
let ok = if toward_white { tone_c >= base_c } else { tone_c <= base_c };
assert!(
ok,
"{name}: channel {channel} of {tone:?} moved the wrong way from base {base:?} \
({tone_c} vs {base_c}); a tone stepped toward white must raise every channel \
and one stepped toward black must lower every channel"
);
}
}
}
#[test]
fn the_derivation_reproduces_the_hand_written_expressions() {
let base = Color::rgb(100, 150, 200);
let bevel = Bevel::from_base(base);
assert_eq!(bevel.light, base.blend(&Color::WHITE, BEVEL_WEIGHT));
assert_eq!(bevel.shade, base.blend(&Color::BLACK, BEVEL_WEIGHT));
let (inner_light, inner_shade) = bevel.inner_tones();
assert_eq!(inner_light, base.blend(&Color::WHITE, BEVEL_INNER_WEIGHT));
assert_eq!(inner_shade, base.blend(&Color::BLACK, BEVEL_INNER_SHADE_WEIGHT));
}
}