diff --git a/Sources/App/SpriteSlicer.swift b/Sources/App/SpriteSlicer.swift index 0d225ef..fe4ff7b 100644 --- a/Sources/App/SpriteSlicer.swift +++ b/Sources/App/SpriteSlicer.swift @@ -84,7 +84,16 @@ enum SpriteSlicer { for col in segments(colHas) { let rect = CGRect(x: col.lower, y: row.lower, width: col.upper - col.lower, height: row.upper - row.lower) - if let cropped = image.cropping(to: rect) { clip.append(cropped) } + guard let cropped = image.cropping(to: rect) else { continue } + let fw = Int(rect.width), fh = Int(rect.height) + let space = CGColorSpaceCreateDeviceRGB() + let info = CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext(data: nil, width: fw, height: fh, + bitsPerComponent: 8, bytesPerRow: fw * 4, + space: space, bitmapInfo: info) else { continue } + ctx.draw(cropped, in: CGRect(origin: .zero, size: CGSize(width: fw, height: fh))) + guard let frame = ctx.makeImage() else { continue } + clip.append(frame) } if !clip.isEmpty { clips.append(clip) } } diff --git a/Tests/AgentPetAppTests/SpriteSlicerTests.swift b/Tests/AgentPetAppTests/SpriteSlicerTests.swift new file mode 100644 index 0000000..a4a85ea --- /dev/null +++ b/Tests/AgentPetAppTests/SpriteSlicerTests.swift @@ -0,0 +1,186 @@ +import XCTest +import CoreGraphics +@testable import agentpet + +// MARK: - Helpers + +/// Synthesises a 2-row x 3-column spritesheet. +/// +/// Layout (image coordinates, y=0 at top): +/// - Outer padding: 6 px on every side +/// - Cell size: 20 x 24 px +/// - Gutter between cells: 8 px (fully transparent) +/// +/// Total sheet: 6 + 20 + 8 + 20 + 8 + 20 + 6 = 88 px wide +/// 6 + 24 + 8 + 24 + 6 = 68 px tall +/// +/// Row 0 (top in image coords): cells filled with red / green / blue +/// Row 1 (bottom in image coords): cells filled with cyan / magenta / yellow +/// +/// CGContext y=0 is at the BOTTOM, so drawing order is inverted vertically. +private func makeSynthesisSheet() -> CGImage { + let cellW = 20, cellH = 24 + let pad = 6, gutter = 8 + let cols = 3, rows = 2 + + let sheetW = pad + cols * cellW + (cols - 1) * gutter + pad + let sheetH = pad + rows * cellH + (rows - 1) * gutter + pad + + // bitmapInfo: premultipliedLast => RGBA + let ctx = CGContext( + data: nil, + width: sheetW, + height: sheetH, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + + // Row 0 colours (top of image -> higher CGContext y) + let row0Colors: [CGColor] = [ + CGColor(red: 1, green: 0, blue: 0, alpha: 1), // red + CGColor(red: 0, green: 1, blue: 0, alpha: 1), // green + CGColor(red: 0, green: 0, blue: 1, alpha: 1), // blue + ] + // Row 1 colours (bottom of image -> lower CGContext y) + let row1Colors: [CGColor] = [ + CGColor(red: 0, green: 1, blue: 1, alpha: 1), // cyan + CGColor(red: 1, green: 0, blue: 1, alpha: 1), // magenta + CGColor(red: 1, green: 1, blue: 0, alpha: 1), // yellow + ] + + // In CGContext: y=0 is image-bottom. + // Image-top row (row 0) starts at image-y = pad + // -> CGContext y = sheetH - pad - cellH + // Image-bottom row (row 1) starts at image-y = pad + cellH + gutter + // -> CGContext y = sheetH - (pad + cellH + gutter) - cellH + + let cgRow0Y = sheetH - pad - cellH + let cgRow1Y = sheetH - pad - 2 * cellH - gutter + + for col in 0.. empty result + + func test_slice_returns_empty_for_fully_transparent_image() { + let ctx = CGContext( + data: nil, + width: 60, + height: 40, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + // Draw nothing -- all pixels remain transparent (alpha = 0). + let blankSheet = ctx.makeImage()! + + let clips = SpriteSlicer.slice(blankSheet) + XCTAssertTrue(clips.isEmpty, "Fully transparent sheet should produce no clips") + } +} + +// MARK: - Pixel helpers + +private func centerPixelRGBA(of image: CGImage) -> (r: UInt8, g: UInt8, b: UInt8, a: UInt8) { + let w = 1, h = 1 + var data = [UInt8](repeating: 0, count: 4) + let ctx = data.withUnsafeMutableBytes { ptr -> CGContext? in + CGContext( + data: ptr.baseAddress, + width: w, height: h, + bitsPerComponent: 8, + bytesPerRow: w * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + }! + // Draw the frame scaled down to 1x1 to sample the centre colour. + ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) + return (data[0], data[1], data[2], data[3]) +} + +/// Returns a string key representing which channel(s) dominate the centre pixel. +private func dominantChannel(of image: CGImage) -> String { + let px = centerPixelRGBA(of: image) + let threshold: UInt8 = 128 + var parts: [String] = [] + if px.r > threshold { parts.append("R") } + if px.g > threshold { parts.append("G") } + if px.b > threshold { parts.append("B") } + return parts.isEmpty ? "none" : parts.joined() +}