///|
pub(all) enum IssueSeverity {
IssueError
IssueWarning
} derive(Eq, @debug.Debug)
///|
pub fn IssueSeverity::to_string(self : IssueSeverity) -> String {
match self {
IssueError => "error"
IssueWarning => "warning"
}
}
///|
pub(all) struct ValidationIssue {
severity : IssueSeverity
path : String
code : String
message : String
} derive(Eq, @debug.Debug)
///|
pub fn ValidationIssue::error(
message : String,
path? : String = "$",
code? : String = "error",
) -> ValidationIssue {
{ severity: IssueError, path, code, message }
}
///|
pub fn ValidationIssue::warning(
message : String,
path? : String = "$",
code? : String = "warning",
) -> ValidationIssue {
{ severity: IssueWarning, path, code, message }
}
///|
pub(all) struct ValidationReport {
ok : Bool
issues : Array[ValidationIssue]
error_count : Int
warning_count : Int
} derive(Eq, @debug.Debug)
///|
pub fn ValidationReport::new(
issues : Array[ValidationIssue],
) -> ValidationReport {
let errors = for issue in issues; total = 0 {
if issue.severity == IssueError {
continue total + 1
} else {
continue total
}
} nobreak {
total
}
let warnings = issues.length() - errors
{ ok: errors == 0, issues, error_count: errors, warning_count: warnings }
}
///|
pub fn ValidationReport::empty() -> ValidationReport {
ValidationReport::new([])
}
///|
pub fn ValidationReport::summary(self : ValidationReport) -> String {
if self.ok {
"ok: \{self.warning_count} warning(s)"
} else {
"failed: \{self.error_count} error(s), \{self.warning_count} warning(s)"
}
}
///|
pub fn validate_sprite_sheet(sheet : SpriteSheet) -> ValidationReport {
let issues : Array[ValidationIssue] = []
validate_sheet_basics(sheet, issues)
validate_frames(sheet, issues)
validate_tags(sheet, issues)
validate_slices(sheet, issues)
validate_layers(sheet, issues)
validate_runtime_readiness(sheet, issues)
ValidationReport::new(issues)
}
///|
fn validate_sheet_basics(
sheet : SpriteSheet,
issues : Array[ValidationIssue],
) -> Unit {
if sheet.frames.length() == 0 {
issues.push(
ValidationIssue::error(
"sprite sheet contains no frames",
path="$.frames",
code="empty-frames",
),
)
}
if sheet.meta.image == "" {
issues.push(
ValidationIssue::warning(
"meta.image is empty; runtime exporters cannot reference the texture",
path="$.meta.image",
code="missing-image",
),
)
}
if !sheet.meta.size.is_positive() {
issues.push(
ValidationIssue::warning(
"meta.size is missing or non-positive",
path="$.meta.size",
code="missing-atlas-size",
),
)
}
}
///|
fn validate_frames(
sheet : SpriteSheet,
issues : Array[ValidationIssue],
) -> Unit {
for i, frame in sheet.frames {
let path = json_index_path("$.frames", i)
if frame.name == "" {
issues.push(
ValidationIssue::error(
"frame name is empty",
path~,
code="empty-frame-name",
),
)
}
if !frame.rect.is_positive() {
issues.push(
ValidationIssue::error(
"frame rectangle must have positive width and height",
path=json_field_path(path, "frame"),
code="invalid-frame-rect",
),
)
}
if frame.duration_ms <= 0 {
issues.push(
ValidationIssue::error(
"frame duration must be greater than zero",
path=json_field_path(path, "duration"),
code="invalid-duration",
),
)
}
if frame.rotated {
issues.push(
ValidationIssue::warning(
"rotated frames require renderer-side UV rotation support",
path~,
code="rotated-frame",
),
)
}
if sheet.meta.size.is_positive() &&
!Rect::new(0, 0, sheet.meta.size.width, sheet.meta.size.height).contains_rect(
frame.rect,
) {
issues.push(
ValidationIssue::error(
"frame rectangle exceeds atlas bounds",
path=json_field_path(path, "frame"),
code="frame-out-of-atlas",
),
)
}
validate_duplicate_frame_name(sheet.frames, i, issues)
}
}
///|
fn validate_duplicate_frame_name(
frames : Array[FrameInfo],
index : Int,
issues : Array[ValidationIssue],
) -> Unit {
let name = frames[index].name
if name != "" {
for j in 0.. Unit {
for i, tag in sheet.meta.frame_tags {
let path = json_index_path("$.meta.frameTags", i)
if tag.name == "" {
issues.push(
ValidationIssue::error(
"tag name is empty",
path~,
code="empty-tag-name",
),
)
}
if tag.from_index < 0 ||
tag.to_index < tag.from_index ||
tag.to_index >= sheet.frames.length() {
issues.push(
ValidationIssue::error(
"tag range is outside available frame indices",
path~,
code="invalid-tag-range",
),
)
}
validate_duplicate_tag_name(sheet.meta.frame_tags, i, issues)
}
}
///|
fn validate_duplicate_tag_name(
tags : Array[FrameTag],
index : Int,
issues : Array[ValidationIssue],
) -> Unit {
let name = tags[index].name
if name != "" {
for j in 0.. Unit {
for i, slice in sheet.meta.slices {
let path = json_index_path("$.meta.slices", i)
if slice.name == "" {
issues.push(
ValidationIssue::error(
"slice name is empty",
path~,
code="empty-slice-name",
),
)
}
if slice.keys.length() == 0 {
issues.push(
ValidationIssue::warning(
"slice contains no keys",
path=json_field_path(path, "keys"),
code="empty-slice-keys",
),
)
}
for k, key in slice.keys {
let key_path = json_index_path(json_field_path(path, "keys"), k)
if key.frame < 0 || key.frame >= sheet.frames.length() {
issues.push(
ValidationIssue::error(
"slice key references a frame outside the sprite sheet",
path=key_path,
code="invalid-slice-frame",
),
)
}
if !key.bounds.is_positive() {
issues.push(
ValidationIssue::error(
"slice bounds must have positive width and height",
path=json_field_path(key_path, "bounds"),
code="invalid-slice-bounds",
),
)
}
}
validate_duplicate_slice_name(sheet.meta.slices, i, issues)
}
}
///|
fn validate_duplicate_slice_name(
slices : Array[SliceInfo],
index : Int,
issues : Array[ValidationIssue],
) -> Unit {
let name = slices[index].name
if name != "" {
for j in 0.. Unit {
for i, layer in sheet.meta.layers {
let path = json_index_path("$.meta.layers", i)
if layer.name == "" {
issues.push(
ValidationIssue::warning(
"layer name is empty",
path~,
code="empty-layer-name",
),
)
}
if layer.opacity < 0 || layer.opacity > 255 {
issues.push(
ValidationIssue::error(
"layer opacity must be in 0..255",
path=json_field_path(path, "opacity"),
code="invalid-layer-opacity",
),
)
}
}
}
///|
fn validate_runtime_readiness(
sheet : SpriteSheet,
issues : Array[ValidationIssue],
) -> Unit {
if sheet.meta.frame_tags.length() == 0 {
issues.push(
ValidationIssue::warning(
"no frameTags found; runtime will create a fallback clip",
path="$.meta.frameTags",
code="missing-tags",
),
)
}
let mut has_box = false
for slice in sheet.meta.slices {
if string_is_box_name(slice.name) {
has_box = true
}
}
if !has_box {
issues.push(
ValidationIssue::warning(
"no hitbox/hurtbox/collision slice found",
path="$.meta.slices",
code="missing-box-slices",
),
)
}
}
///|
pub fn validate_ase_file(file : AseFile) -> ValidationReport {
let issues : Array[ValidationIssue] = []
if file.header.width <= 0 || file.header.height <= 0 {
issues.push(
ValidationIssue::error(
"ASE canvas size must be positive",
path="header",
code="invalid-canvas-size",
),
)
}
if file.header.frame_count != file.frames.length() {
issues.push(
ValidationIssue::error(
"ASE parsed frame count does not match header",
path="frames",
code="frame-count-mismatch",
),
)
}
if file.header.color_depth != 32 &&
file.header.color_depth != 16 &&
file.header.color_depth != 8 {
issues.push(
ValidationIssue::warning(
"uncommon color depth; renderer adapter may need custom handling",
path="header.colorDepth",
code="uncommon-color-depth",
),
)
}
if ase_has_compressed_cels(file) {
issues.push(
ValidationIssue::warning(
"compressed cel pixels are detected; v1 exposes metadata and leaves decompression to future zlib integration",
path="chunks",
code="compressed-cel",
),
)
}
ValidationReport::new(issues)
}