|
|
|
@@ -0,0 +1,401 @@
|
|
|
|
|
use super::model::BindingArea;
|
|
|
|
|
use image::RgbaImage;
|
|
|
|
|
|
|
|
|
|
/// Each edge may move by at most half of the area dimension returned by the
|
|
|
|
|
/// visual model. Keep this policy explicit so changing it is an intentional
|
|
|
|
|
/// workflow decision rather than a scattered numeric literal.
|
|
|
|
|
pub(crate) const MAX_BINDING_AREA_EDGE_ADJUSTMENT_PERCENT: u32 = 50;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
|
|
|
pub(crate) struct NormalizedBindingArea {
|
|
|
|
|
pub(crate) area: BindingArea,
|
|
|
|
|
pub(crate) changed: bool,
|
|
|
|
|
pub(crate) clamped: bool,
|
|
|
|
|
pub(crate) transparent: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum EdgeDirection {
|
|
|
|
|
Inward,
|
|
|
|
|
Outward,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
struct Rect {
|
|
|
|
|
left: u32,
|
|
|
|
|
top: u32,
|
|
|
|
|
right: u32,
|
|
|
|
|
bottom: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Rect {
|
|
|
|
|
fn from_area(area: BindingArea) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
left: area.global_pos_x_px,
|
|
|
|
|
top: area.global_pos_y_px,
|
|
|
|
|
right: area.global_pos_x_px + area.width_px,
|
|
|
|
|
bottom: area.global_pos_y_px + area.height_px,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn into_area(self) -> BindingArea {
|
|
|
|
|
BindingArea {
|
|
|
|
|
global_pos_x_px: self.left,
|
|
|
|
|
global_pos_y_px: self.top,
|
|
|
|
|
width_px: self.right - self.left,
|
|
|
|
|
height_px: self.bottom - self.top,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum Edge {
|
|
|
|
|
Left,
|
|
|
|
|
Right,
|
|
|
|
|
Top,
|
|
|
|
|
Bottom,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Edge {
|
|
|
|
|
const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool {
|
|
|
|
|
match edge {
|
|
|
|
|
Edge::Left | Edge::Right => {
|
|
|
|
|
let x = if edge == Edge::Left {
|
|
|
|
|
rect.left
|
|
|
|
|
} else {
|
|
|
|
|
rect.right - 1
|
|
|
|
|
};
|
|
|
|
|
(rect.top..rect.bottom).any(|y| image.get_pixel(x, y).0[3] > 0)
|
|
|
|
|
}
|
|
|
|
|
Edge::Top | Edge::Bottom => {
|
|
|
|
|
let y = if edge == Edge::Top {
|
|
|
|
|
rect.top
|
|
|
|
|
} else {
|
|
|
|
|
rect.bottom - 1
|
|
|
|
|
};
|
|
|
|
|
(rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rect_has_visible_pixel(image: &RgbaImage, rect: Rect) -> bool {
|
|
|
|
|
(rect.top..rect.bottom).any(|y| (rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn max_edge_adjustment(dimension: u32) -> u32 {
|
|
|
|
|
((u64::from(dimension) * u64::from(MAX_BINDING_AREA_EDGE_ADJUSTMENT_PERCENT)) / 100)
|
|
|
|
|
.min(u64::from(u32::MAX)) as u32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_direction(image: &RgbaImage, rect: Rect, edge: Edge) -> EdgeDirection {
|
|
|
|
|
if edge_has_visible_pixel(image, rect, edge) {
|
|
|
|
|
EdgeDirection::Outward
|
|
|
|
|
} else {
|
|
|
|
|
EdgeDirection::Inward
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn move_edge(rect: &mut Rect, edge: Edge, direction: EdgeDirection) {
|
|
|
|
|
match (edge, direction) {
|
|
|
|
|
(Edge::Left, EdgeDirection::Inward) => rect.left += 1,
|
|
|
|
|
(Edge::Left, EdgeDirection::Outward) => rect.left -= 1,
|
|
|
|
|
(Edge::Right, EdgeDirection::Inward) => rect.right -= 1,
|
|
|
|
|
(Edge::Right, EdgeDirection::Outward) => rect.right += 1,
|
|
|
|
|
(Edge::Top, EdgeDirection::Inward) => rect.top += 1,
|
|
|
|
|
(Edge::Top, EdgeDirection::Outward) => rect.top -= 1,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Inward) => rect.bottom -= 1,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Outward) => rect.bottom += 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_coordinate(rect: Rect, edge: Edge) -> u32 {
|
|
|
|
|
match edge {
|
|
|
|
|
Edge::Left => rect.left,
|
|
|
|
|
Edge::Right => rect.right,
|
|
|
|
|
Edge::Top => rect.top,
|
|
|
|
|
Edge::Bottom => rect.bottom,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_displacement(original: Rect, current: Rect, edge: Edge) -> u32 {
|
|
|
|
|
edge_coordinate(original, edge).abs_diff(edge_coordinate(current, edge))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_adjustment_limit(original: Rect, edge: Edge) -> u32 {
|
|
|
|
|
max_edge_adjustment(match edge {
|
|
|
|
|
Edge::Left | Edge::Right => original.right - original.left,
|
|
|
|
|
Edge::Top | Edge::Bottom => original.bottom - original.top,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reached_adjustment_limit(original: Rect, current: Rect, edge: Edge) -> bool {
|
|
|
|
|
edge_displacement(original, current, edge) >= edge_adjustment_limit(original, edge)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn can_move_geometrically(
|
|
|
|
|
image: &RgbaImage,
|
|
|
|
|
current: Rect,
|
|
|
|
|
edge: Edge,
|
|
|
|
|
direction: EdgeDirection,
|
|
|
|
|
) -> bool {
|
|
|
|
|
match (edge, direction) {
|
|
|
|
|
(Edge::Left, EdgeDirection::Inward) => current.left + 1 < current.right,
|
|
|
|
|
(Edge::Left, EdgeDirection::Outward) => current.left > 0,
|
|
|
|
|
(Edge::Right, EdgeDirection::Inward) => current.right > current.left + 1,
|
|
|
|
|
(Edge::Right, EdgeDirection::Outward) => current.right < image.width(),
|
|
|
|
|
(Edge::Top, EdgeDirection::Inward) => current.top + 1 < current.bottom,
|
|
|
|
|
(Edge::Top, EdgeDirection::Outward) => current.top > 0,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Inward) => current.bottom > current.top + 1,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Outward) => current.bottom < image.height(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn next_edge_rect(rect: Rect, edge: Edge, direction: EdgeDirection) -> Option<Rect> {
|
|
|
|
|
let mut next = rect;
|
|
|
|
|
match (edge, direction) {
|
|
|
|
|
(Edge::Left, EdgeDirection::Inward) if rect.left + 1 < rect.right => next.left += 1,
|
|
|
|
|
(Edge::Left, EdgeDirection::Outward) if rect.left > 0 => next.left -= 1,
|
|
|
|
|
(Edge::Right, EdgeDirection::Inward) if rect.right > rect.left + 1 => next.right -= 1,
|
|
|
|
|
(Edge::Right, EdgeDirection::Outward) => next.right = next.right.checked_add(1)?,
|
|
|
|
|
(Edge::Top, EdgeDirection::Inward) if rect.top + 1 < rect.bottom => next.top += 1,
|
|
|
|
|
(Edge::Top, EdgeDirection::Outward) if rect.top > 0 => next.top -= 1,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Inward) if rect.bottom > rect.top + 1 => next.bottom -= 1,
|
|
|
|
|
(Edge::Bottom, EdgeDirection::Outward) => next.bottom = next.bottom.checked_add(1)?,
|
|
|
|
|
_ => return None,
|
|
|
|
|
}
|
|
|
|
|
Some(next)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edge_requires_move(image: &RgbaImage, rect: Rect, edge: Edge, direction: EdgeDirection) -> bool {
|
|
|
|
|
match direction {
|
|
|
|
|
EdgeDirection::Inward => !edge_has_visible_pixel(image, rect, edge),
|
|
|
|
|
EdgeDirection::Outward => {
|
|
|
|
|
if !edge_has_visible_pixel(image, rect, edge) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if !can_move_geometrically(image, rect, edge, direction) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
next_edge_rect(rect, edge, direction)
|
|
|
|
|
.is_some_and(|next| edge_has_visible_pixel(image, next, edge))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_edge_step(
|
|
|
|
|
image: &RgbaImage,
|
|
|
|
|
original: Rect,
|
|
|
|
|
current: Rect,
|
|
|
|
|
edge: Edge,
|
|
|
|
|
direction: EdgeDirection,
|
|
|
|
|
) -> (Rect, bool, bool) {
|
|
|
|
|
if !edge_requires_move(image, current, edge, direction) {
|
|
|
|
|
return (current, false, false);
|
|
|
|
|
}
|
|
|
|
|
if reached_adjustment_limit(original, current, edge)
|
|
|
|
|
|| !can_move_geometrically(image, current, edge, direction)
|
|
|
|
|
{
|
|
|
|
|
return (current, false, true);
|
|
|
|
|
}
|
|
|
|
|
let mut next = current;
|
|
|
|
|
move_edge(&mut next, edge, direction);
|
|
|
|
|
(next, true, false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Normalizes a model-provided area using visible pixels on the processed
|
|
|
|
|
/// transparent image. Each edge chooses inward/outward direction once from
|
|
|
|
|
/// its initial scan and then moves monotonically, so sparse pixels cannot make
|
|
|
|
|
/// the boundary oscillate. The four edge steps are calculated from the same
|
|
|
|
|
/// rectangle on each round.
|
|
|
|
|
pub(crate) fn normalize_binding_area(
|
|
|
|
|
image: &RgbaImage,
|
|
|
|
|
original_area: BindingArea,
|
|
|
|
|
) -> Result<NormalizedBindingArea, String> {
|
|
|
|
|
original_area.validate_in(image.width(), image.height())?;
|
|
|
|
|
let original = Rect::from_area(original_area);
|
|
|
|
|
let directions = Edge::ALL.map(|edge| edge_direction(image, original, edge));
|
|
|
|
|
let mut current = original;
|
|
|
|
|
let mut clamped = false;
|
|
|
|
|
let mut active = [true; 4];
|
|
|
|
|
|
|
|
|
|
// TODO: Replace the deliberately simple pixel-by-pixel scan if real UI
|
|
|
|
|
// design sizes show this path to be a measurable bottleneck.
|
|
|
|
|
while active.iter().any(|value| *value) {
|
|
|
|
|
let before = current;
|
|
|
|
|
let mut next = current;
|
|
|
|
|
let mut moved = [false; 4];
|
|
|
|
|
for (index, edge) in Edge::ALL.into_iter().enumerate() {
|
|
|
|
|
if !active[index] {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let (candidate, did_move, reached_limit) =
|
|
|
|
|
apply_edge_step(image, original, current, edge, directions[index]);
|
|
|
|
|
if reached_limit {
|
|
|
|
|
clamped = true;
|
|
|
|
|
active[index] = false;
|
|
|
|
|
} else if !did_move {
|
|
|
|
|
active[index] = false;
|
|
|
|
|
}
|
|
|
|
|
moved[index] = did_move;
|
|
|
|
|
match edge {
|
|
|
|
|
Edge::Left => next.left = candidate.left,
|
|
|
|
|
Edge::Right => next.right = candidate.right,
|
|
|
|
|
Edge::Top => next.top = candidate.top,
|
|
|
|
|
Edge::Bottom => next.bottom = candidate.bottom,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if next.left >= next.right {
|
|
|
|
|
clamped = true;
|
|
|
|
|
if moved[0] {
|
|
|
|
|
active[0] = false;
|
|
|
|
|
}
|
|
|
|
|
if moved[1] {
|
|
|
|
|
active[1] = false;
|
|
|
|
|
}
|
|
|
|
|
next.left = current.left;
|
|
|
|
|
next.right = current.right;
|
|
|
|
|
}
|
|
|
|
|
if next.top >= next.bottom {
|
|
|
|
|
clamped = true;
|
|
|
|
|
if moved[2] {
|
|
|
|
|
active[2] = false;
|
|
|
|
|
}
|
|
|
|
|
if moved[3] {
|
|
|
|
|
active[3] = false;
|
|
|
|
|
}
|
|
|
|
|
next.top = current.top;
|
|
|
|
|
next.bottom = current.bottom;
|
|
|
|
|
}
|
|
|
|
|
current = next;
|
|
|
|
|
if current == before {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let area = current.into_area();
|
|
|
|
|
Ok(NormalizedBindingArea {
|
|
|
|
|
changed: area != original_area,
|
|
|
|
|
area,
|
|
|
|
|
clamped,
|
|
|
|
|
transparent: !rect_has_visible_pixel(image, current),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use image::{Rgba, RgbaImage};
|
|
|
|
|
|
|
|
|
|
fn image_with_rect(
|
|
|
|
|
width: u32,
|
|
|
|
|
height: u32,
|
|
|
|
|
left: u32,
|
|
|
|
|
top: u32,
|
|
|
|
|
right: u32,
|
|
|
|
|
bottom: u32,
|
|
|
|
|
) -> RgbaImage {
|
|
|
|
|
let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
|
|
|
|
|
for y in top..bottom {
|
|
|
|
|
for x in left..right {
|
|
|
|
|
image.put_pixel(x, y, Rgba([255, 255, 255, 255]));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
image
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn area(x: u32, y: u32, width: u32, height: u32) -> BindingArea {
|
|
|
|
|
BindingArea {
|
|
|
|
|
global_pos_x_px: x,
|
|
|
|
|
global_pos_y_px: y,
|
|
|
|
|
width_px: width,
|
|
|
|
|
height_px: height,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shrinks_empty_edges_to_visible_bounds() {
|
|
|
|
|
let image = image_with_rect(32, 32, 10, 11, 16, 18);
|
|
|
|
|
let result = normalize_binding_area(&image, area(6, 7, 14, 16)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(10, 11, 6, 7));
|
|
|
|
|
assert!(result.changed);
|
|
|
|
|
assert!(!result.clamped);
|
|
|
|
|
assert!(!result.transparent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn expands_visible_edges_to_cover_the_element() {
|
|
|
|
|
let image = image_with_rect(32, 32, 10, 11, 16, 18);
|
|
|
|
|
let result = normalize_binding_area(&image, area(11, 12, 4, 5)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(10, 11, 6, 7));
|
|
|
|
|
assert!(result.changed);
|
|
|
|
|
assert!(!result.clamped);
|
|
|
|
|
assert!(!result.transparent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn adjusts_each_edge_independently() {
|
|
|
|
|
let image = image_with_rect(32, 32, 10, 11, 16, 18);
|
|
|
|
|
let result = normalize_binding_area(&image, area(10, 12, 10, 3)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(10, 11, 6, 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn keeps_nonzero_alpha_antialias_pixels() {
|
|
|
|
|
let mut image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0]));
|
|
|
|
|
image.put_pixel(5, 6, Rgba([255, 255, 255, 1]));
|
|
|
|
|
image.put_pixel(7, 8, Rgba([255, 255, 255, 255]));
|
|
|
|
|
let result = normalize_binding_area(&image, area(4, 5, 5, 5)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(5, 6, 3, 3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn fully_transparent_image_uses_the_same_path() {
|
|
|
|
|
let image = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
|
|
|
|
|
let result = normalize_binding_area(&image, area(10, 10, 10, 10)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(14, 14, 2, 2));
|
|
|
|
|
assert!(result.changed);
|
|
|
|
|
assert!(result.transparent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn caps_each_edge_at_half_of_the_original_dimension() {
|
|
|
|
|
let image = image_with_rect(64, 64, 0, 0, 64, 64);
|
|
|
|
|
let result = normalize_binding_area(&image, area(16, 16, 8, 8)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(12, 12, 16, 16));
|
|
|
|
|
assert!(result.clamped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn clamps_expansion_to_image_edges() {
|
|
|
|
|
let image = image_with_rect(16, 16, 0, 0, 4, 4);
|
|
|
|
|
let result = normalize_binding_area(&image, area(1, 1, 2, 2)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(0, 0, 4, 4));
|
|
|
|
|
assert!(result.clamped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn exact_split_at_adjustment_limit_is_not_clamped() {
|
|
|
|
|
let image = image_with_rect(16, 16, 4, 4, 8, 8);
|
|
|
|
|
let result = normalize_binding_area(&image, area(5, 5, 2, 2)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(4, 4, 4, 4));
|
|
|
|
|
assert!(!result.clamped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn one_pixel_area_stays_nonzero_when_adjustment_limit_is_zero() {
|
|
|
|
|
let image = image_with_rect(8, 8, 2, 2, 5, 5);
|
|
|
|
|
let result = normalize_binding_area(&image, area(3, 3, 1, 1)).unwrap();
|
|
|
|
|
assert_eq!(result.area, area(3, 3, 1, 1));
|
|
|
|
|
assert!(result.clamped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_zero_sized_or_out_of_bounds_model_areas() {
|
|
|
|
|
let image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0]));
|
|
|
|
|
assert!(normalize_binding_area(&image, area(0, 0, 0, 1)).is_err());
|
|
|
|
|
assert!(normalize_binding_area(&image, area(15, 15, 2, 2)).is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|