///|
pub fn severity_name(severity : IssueSeverity) -> String {
match severity {
Info => "info"
Warning => "warning"
Critical => "critical"
}
}
///|
pub fn issue_kind_name(kind : IssueKind) -> String {
match kind {
LowOccupancy => "low-occupancy"
HasUnpacked => "has-unpacked"
WideBoundingBox => "wide-bounding-box"
TallBoundingBox => "tall-bounding-box"
SparseGrid => "sparse-grid"
HeavyRotation => "heavy-rotation"
FragmentedRows => "fragmented-rows"
ConstraintFailed => "constraint-failed"
SourceRisk => "source-risk"
}
}
///|
fn make_issue(
severity : IssueSeverity,
kind : IssueKind,
id : String,
message : String,
value : Int,
) -> LayoutIssue {
LayoutIssue::{ severity, kind, id, message, value }
}
///|
fn push_issue(
issues : Array[LayoutIssue],
severity : IssueSeverity,
kind : IssueKind,
id : String,
message : String,
value : Int,
) -> Unit {
issues.push(make_issue(severity, kind, id, message, value))
}
///|
fn diagnose_low_occupancy(
issues : Array[LayoutIssue],
stats : LayoutStats,
) -> Unit {
if stats.placed_count == 0 {
push_issue(
issues,
Critical,
LowOccupancy,
"layout",
"no rectangle was placed",
stats.occupancy_permille,
)
} else if stats.occupancy_permille < 350 {
push_issue(
issues,
Warning,
LowOccupancy,
"layout",
"container occupancy is low",
stats.occupancy_permille,
)
} else if stats.occupancy_permille >= 750 {
push_issue(
issues,
Info,
LowOccupancy,
"layout",
"container occupancy is strong",
stats.occupancy_permille,
)
}
}
///|
fn diagnose_unpacked(issues : Array[LayoutIssue], stats : LayoutStats) -> Unit {
if stats.unpacked_count > 0 {
push_issue(
issues,
Critical,
HasUnpacked,
"layout",
"some rectangles were not packed",
stats.unpacked_count,
)
}
}
///|
fn diagnose_bounding_box(
issues : Array[LayoutIssue],
layout : Layout,
stats : LayoutStats,
) -> Unit {
let width_ratio = scale_permille(stats.bounding_width, layout.bin.width)
let height_ratio = scale_permille(stats.bounding_height, layout.bin.height)
if width_ratio > 900 && stats.occupancy_permille < 600 {
push_issue(
issues,
Warning,
WideBoundingBox,
"layout",
"placements stretch across most of the width while occupancy is modest",
width_ratio,
)
}
if height_ratio > 900 && stats.occupancy_permille < 600 {
push_issue(
issues,
Warning,
TallBoundingBox,
"layout",
"placements stretch across most of the height while occupancy is modest",
height_ratio,
)
}
}
///|
fn diagnose_fragmentation(
issues : Array[LayoutIssue],
stats : LayoutStats,
) -> Unit {
if stats.fragmentation_score > 650 {
push_issue(
issues,
Warning,
FragmentedRows,
"layout",
"row and column anchors look fragmented",
stats.fragmentation_score,
)
} else if stats.fragmentation_score < 150 && stats.placed_count > 2 {
push_issue(
issues,
Info,
FragmentedRows,
"layout",
"row and column anchors are compact",
stats.fragmentation_score,
)
}
}
///|
fn diagnose_rotation(issues : Array[LayoutIssue], stats : LayoutStats) -> Unit {
if stats.placed_count == 0 {
return
}
let ratio = scale_permille(stats.rotated_count, stats.placed_count)
if ratio > 700 {
push_issue(
issues,
Warning,
HeavyRotation,
"layout",
"most placements require rotation",
ratio,
)
} else if ratio > 0 {
push_issue(
issues,
Info,
HeavyRotation,
"layout",
"some placements use rotation",
ratio,
)
}
}
///|
fn diagnose_grid_sparsity(issues : Array[LayoutIssue], layout : Layout) -> Unit {
let cell_w = (layout.bin.width / 4).max(1)
let cell_h = (layout.bin.height / 4).max(1)
match density_grid(layout, cell_width=cell_w, cell_height=cell_h) {
Err(_) => ()
Ok(report) => {
let empty_ratio = scale_permille(
report.empty_cells,
report.cells.length(),
)
if empty_ratio > 700 && !layout.placements.is_empty() {
push_issue(
issues,
Warning,
SparseGrid,
"layout",
"coarse grid has many empty cells",
empty_ratio,
)
}
}
}
}
///|
/// Diagnose a completed layout and return structured findings.
pub fn diagnose_layout(
layout : Layout,
) -> Result[Array[LayoutIssue], PackError] {
match analyze_layout(layout) {
Err(err) => Err(err)
Ok(stats) => {
let issues : Array[LayoutIssue] = []
diagnose_low_occupancy(issues, stats)
diagnose_unpacked(issues, stats)
diagnose_bounding_box(issues, layout, stats)
diagnose_fragmentation(issues, stats)
diagnose_rotation(issues, stats)
diagnose_grid_sparsity(issues, layout)
Ok(issues)
}
}
}
///|
fn diagnose_invalid_rects(
issues : Array[LayoutIssue],
rects : ArrayView[Rect],
) -> Unit {
for rect in rects {
if rect.id.is_empty() {
push_issue(
issues,
Critical,
SourceRisk,
"rect",
"rectangle id is empty",
0,
)
}
if rect.width <= 0 || rect.height <= 0 {
push_issue(
issues,
Critical,
SourceRisk,
rect.id,
"rectangle size must be positive",
rect.width * rect.height,
)
}
}
}
///|
fn diagnose_duplicate_rects(
issues : Array[LayoutIssue],
rects : ArrayView[Rect],
) -> Unit {
match maybe_duplicate_id(rects) {
Some(id) =>
push_issue(issues, Critical, SourceRisk, id, "duplicate rectangle id", 1)
None => ()
}
}
///|
fn diagnose_rect_shape_mix(
issues : Array[LayoutIssue],
stats : RectSetStats,
) -> Unit {
if stats.count == 0 {
push_issue(
issues,
Critical,
SourceRisk,
"rects",
"input rectangle collection is empty",
0,
)
return
}
if stats.max_area > stats.min_area * 20 && stats.min_area > 0 {
push_issue(
issues,
Warning,
SourceRisk,
"rects",
"rectangle areas vary widely",
scale_permille(stats.max_area, stats.min_area),
)
}
if stats.oversized_count > 0 {
push_issue(
issues,
Critical,
SourceRisk,
"rects",
"some rectangles are larger than the target bin",
stats.oversized_count,
)
}
if stats.wide_count == stats.count || stats.tall_count == stats.count {
push_issue(
issues,
Info,
SourceRisk,
"rects",
"all rectangles share the same broad orientation",
stats.count,
)
}
}
///|
/// Diagnose source rectangles against a target bin.
pub fn diagnose_rects_for_bin(
rects : ArrayView[Rect],
bin : Bin,
) -> Array[LayoutIssue] {
let issues : Array[LayoutIssue] = []
diagnose_invalid_rects(issues, rects)
diagnose_duplicate_rects(issues, rects)
diagnose_rect_shape_mix(issues, summarize_rects_for_bin(rects, bin))
issues
}
///|
/// Diagnose source rectangles without a target bin.
pub fn diagnose_rects(rects : ArrayView[Rect]) -> Array[LayoutIssue] {
let issues : Array[LayoutIssue] = []
diagnose_invalid_rects(issues, rects)
diagnose_duplicate_rects(issues, rects)
diagnose_rect_shape_mix(issues, summarize_rects(rects))
issues
}
///|
fn count_severity(
issues : ArrayView[LayoutIssue],
severity : IssueSeverity,
) -> Int {
let mut count = 0
for issue in issues {
if issue.severity == severity {
count = count + 1
}
}
count
}
///|
fn write_issue(out : StringBuilder, issue : LayoutIssue) -> Unit {
out.write_string("- [")
out.write_string(severity_name(issue.severity))
out.write_string("] ")
out.write_string(issue_kind_name(issue.kind))
out.write_string(" ")
out.write_string(issue.id)
out.write_string(": ")
out.write_string(issue.message)
out.write_string(" (")
out.write_string("\{issue.value}")
out.write_string(")")
out.write_char('\n')
}
///|
/// Human-readable diagnostic report.
pub fn to_diagnostics_report(issues : ArrayView[LayoutIssue]) -> String {
let out = StringBuilder()
out.write_string("rectloom diagnostics\n")
out.write_string("critical=")
out.write_string("\{count_severity(issues, Critical)}")
out.write_string(" warning=")
out.write_string("\{count_severity(issues, Warning)}")
out.write_string(" info=")
out.write_string("\{count_severity(issues, Info)}")
out.write_char('\n')
for issue in issues {
write_issue(out, issue)
}
out.to_string()
}
///|
/// Convenience wrapper for layout diagnostics.
pub fn diagnose_layout_report(layout : Layout) -> Result[String, PackError] {
match diagnose_layout(layout) {
Err(err) => Err(err)
Ok(issues) => Ok(to_diagnostics_report(issues))
}
}