package ru.dbotthepony.kstarbound.defs.image import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine import com.google.common.collect.ImmutableList import com.google.gson.JsonArray import com.google.gson.JsonNull import com.google.gson.JsonObject import com.google.gson.JsonSyntaxException import com.google.gson.stream.JsonReader import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap import org.apache.logging.log4j.LogManager import org.lwjgl.stb.STBImage import org.lwjgl.system.MemoryUtil import ru.dbotthepony.kstarbound.PIXELS_IN_STARBOUND_UNIT import ru.dbotthepony.kstarbound.PIXELS_IN_STARBOUND_UNITi import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.api.IStarboundFile import ru.dbotthepony.kstarbound.io.stream2STBIO import ru.dbotthepony.kstarbound.util.contains import ru.dbotthepony.kstarbound.util.get import ru.dbotthepony.kstarbound.util.getObject import ru.dbotthepony.kvector.vector.Vector2i import ru.dbotthepony.kvector.vector.Vector4i import java.io.BufferedInputStream import java.io.FileNotFoundException import java.lang.ref.Cleaner import java.lang.ref.WeakReference import java.nio.ByteBuffer import java.time.Duration import java.util.Collections import java.util.Optional import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.locks.ReentrantLock class Image private constructor( val source: IStarboundFile, val path: String, val width: Int, val height: Int, val amountOfChannels: Int, sprites: List? ) { init { check(width >= 0) { "Invalid width $width" } check(height >= 0) { "Invalid height $height" } check(amountOfChannels in 1 .. 4) { "Unknown number of channels $amountOfChannels" } } private val spritesInternal = Object2ObjectLinkedOpenHashMap() private var dataRef: WeakReference? = null private val lock = ReentrantLock() init { if (sprites == null) { this.spritesInternal["default"] = Sprite("default", 0, 0, width, height) } else { for (data in sprites) { this.spritesInternal[data.name] = Sprite( data.name, data.coordinates.x, data.coordinates.y, data.coordinates.z - data.coordinates.x, data.coordinates.w - data.coordinates.y) } } } val data: ByteBuffer get() { var get = dataRef?.get() if (get != null) return get lock.lock() try { get = dataRef?.get() if (get != null) return get get = dataCache.get(path) { val getWidth = intArrayOf(0) val getHeight = intArrayOf(0) val components = intArrayOf(0) val data = STBImage.stbi_load_from_memory( source.readDirect(), getWidth, getHeight, components, 0 ) ?: throw IllegalArgumentException("File $source is not an image or it is corrupted") val address = MemoryUtil.memAddress(data) cleaner.register(data) { STBImage.nstbi_image_free(address) } check(getWidth[0] == width && getHeight[0] == height && components[0] == amountOfChannels) { "Actual loaded image differs from constructed (this $width x $height with $amountOfChannels channels; loaded ${getWidth[0]} x ${getHeight[0]} with ${components[0]} channels)" } data } dataRef = WeakReference(get) return get } finally { lock.unlock() } } val size = Vector2i(width, height) val sprites: Map = Collections.unmodifiableMap(this.spritesInternal) val first: Sprite = this.spritesInternal.values.first() val whole = Sprite("this", 0, 0, width, height) val nonEmptyRegion get() = whole.nonEmptyRegion operator fun get(x: Int, y: Int): Int { return whole[x, y] } operator fun get(x: Int, y: Int, flip: Boolean): Int { return whole[x, y, flip] } operator fun get(name: String): Sprite? { return spritesInternal[name] } fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean { return whole.isTransparent(x, y, flip) } fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List { return whole.worldSpaces(pixelOffset, spaceScan, flip) } private data class DataSprite(val name: String, val coordinates: Vector4i) inner class Sprite(val name: String, val x: Int, val y: Int, val width: Int, val height: Int) : IUVCoordinates { // flip coordinates to account for opengl override val u0: Float = x.toFloat() / this@Image.width override val v1: Float = y.toFloat() / this@Image.height override val u1: Float = (x.toFloat() + this.width.toFloat()) / this@Image.width override val v0: Float = (y.toFloat() + this.height.toFloat()) / this@Image.height operator fun get(x: Int, y: Int): Int { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } val offset = (this.y + y) * this@Image.width * amountOfChannels + (this.x + x) * amountOfChannels val data = data when (amountOfChannels) { 4 -> return data[offset].toInt() or data[offset + 1].toInt().shl(8) or data[offset + 2].toInt().shl(16) or data[offset + 3].toInt().shl(24) 3 -> return data[offset].toInt() or data[offset + 1].toInt().shl(8) or data[offset + 2].toInt().shl(16) 2 -> return data[offset].toInt() or data[offset + 1].toInt().shl(8) 1 -> return data[offset].toInt() else -> throw IllegalStateException() } } operator fun get(x: Int, y: Int, flip: Boolean): Int { if (flip) { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } return this[width - x - 1, y] } else { return this[x, y] } } fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean { if (x !in 0 until width) return true if (y !in 0 until height) return true if (amountOfChannels != 4) return false return this[x, y, flip] and 0xFF != 0x0 } val nonEmptyRegion by lazy { if (amountOfChannels == 4) { var x0 = 0 var y0 = 0 search@for (y in 0 until height) { for (x in 0 until width) { if (this[x, y] and 0xFF != 0x0) { x0 = x y0 = y break@search } } } var x1 = x0 var y1 = y0 search@for (y in height - 1 downTo y0) { for (x in width - 1 downTo x0) { if (this[x, y] and 0xFF != 0x0) { x1 = x y1 = y break@search } } } return@lazy Vector4i(x0, y0, x1, y1) } Vector4i(0, 0, width, height) } fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List { if (amountOfChannels != 3 && amountOfChannels != 4) throw IllegalStateException("Can not check world space taken by image with $amountOfChannels color channels") val xDivL = pixelOffset.x % PIXELS_IN_STARBOUND_UNITi val yDivB = pixelOffset.y % PIXELS_IN_STARBOUND_UNITi val xDivR = (pixelOffset.x + width) % PIXELS_IN_STARBOUND_UNITi val yDivT = (pixelOffset.y + height) % PIXELS_IN_STARBOUND_UNITi val leftMostX = pixelOffset.x / PIXELS_IN_STARBOUND_UNITi - (if (xDivL != 0) 1 else 0) val bottomMostY = pixelOffset.y / PIXELS_IN_STARBOUND_UNITi - (if (yDivB != 0) 1 else 0) val rightMostX = (pixelOffset.x + width) / PIXELS_IN_STARBOUND_UNITi + (if (xDivR != 0) 1 else 0) val topMostY = (pixelOffset.y + height) / PIXELS_IN_STARBOUND_UNITi + (if (yDivT != 0) 1 else 0) val result = ArrayList() for (y in bottomMostY .. topMostY) { for (x in leftMostX .. rightMostX) { val left = x * PIXELS_IN_STARBOUND_UNITi val bottom = y * PIXELS_IN_STARBOUND_UNITi var transparentPixels = 0 for (sX in 0 until PIXELS_IN_STARBOUND_UNITi) { for (sY in 0 until PIXELS_IN_STARBOUND_UNITi) { if (isTransparent(xDivL + sX + left, yDivB + sY + bottom, flip)) { transparentPixels++ } } } if (transparentPixels * FILL_RATIO >= spaceScan) { result.add(Vector2i(x, y)) } } } return result } } companion object { const val FILL_RATIO = 1 / (PIXELS_IN_STARBOUND_UNIT * PIXELS_IN_STARBOUND_UNIT) private val objects by lazy { Starbound.gson.getAdapter(JsonObject::class.java) } private val vectors by lazy { Starbound.gson.getAdapter(Vector4i::class.java) } private val vectors2 by lazy { Starbound.gson.getAdapter(Vector2i::class.java) } private val cache = ConcurrentHashMap>>() private val imageCache = ConcurrentHashMap>() private val logger = LogManager.getLogger() private val cleaner = Cleaner.create { Thread(it, "STB Image Cleaner") } private val dataCache: Cache = Caffeine.newBuilder() .softValues() .expireAfterAccess(Duration.ofMinutes(20)) .weigher { key, value -> value.capacity() } .maximumWeight(1_024L * 1_024L * 256L /* 256 МиБ */) .build() @JvmStatic fun get(path: String): Image? { return imageCache.computeIfAbsent(path) { try { val file = Starbound.locate(it) if (!file.exists) { throw FileNotFoundException("No such file $it") } if (!file.isFile) { throw FileNotFoundException("File $it is a directory") } val getWidth = intArrayOf(0) val getHeight = intArrayOf(0) val components = intArrayOf(0) val status = STBImage.stbi_info_from_callbacks( stream2STBIO(BufferedInputStream(file.open())), 0L, getWidth, getHeight, components ) if (!status) throw IllegalArgumentException("File $file is not an image or it is corrupted") Optional.of(Image(file, it, getWidth[0], getHeight[0], components[0], getConfig(it))) } catch (err: Exception) { logger.error("Failed to load image at path $it", err) Optional.empty() } }.orElse(null) } private fun generateFakeNames(dimensions: Vector2i): JsonArray { return JsonArray(dimensions.y).also { var stripElem = 0 for (stripNum in 0 until dimensions.y) { val strip = JsonArray(dimensions.x) for (i in 0 until dimensions.x) { strip.add(stripElem.toString()) stripElem++ } it.add(strip) } } } private fun parseFrames(read: JsonObject): List { val begin = read.get("begin", vectors2) { Vector2i.ZERO } val sprites = LinkedHashMap() if ("frameGrid" in read) { val frameGrid = read.getObject("frameGrid") val size = vectors2.fromJsonTree(frameGrid["size"] ?: throw JsonSyntaxException("Missing frameGrid.size")) val dimensions = vectors2.fromJsonTree(frameGrid["dimensions"] ?: throw JsonSyntaxException("Missing frameGrid.dimensions")) require(size.x >= 0 && size.y >= 0) { "Invalid size: $size" } require(dimensions.x > 0 && dimensions.y > 0) { "Invalid dimensions: $dimensions" } val names = (frameGrid.get("names") { generateFakeNames(dimensions) }) .map { (it as? JsonArray)?.map { if (it == JsonNull.INSTANCE) null else it.asString } }.toList() for ((y, strip) in names.withIndex()) { // разрешаем вставлять null как ленту кадров, что означает что мы должны пропустить её полностью if (strip == null) continue for ((x, spriteName) in strip.withIndex()) { // если кадр не имеет имени... if (spriteName == null) continue require(y < dimensions.y && x < dimensions.x) { "Sprite at $x $y is out of bounds for frame grid with dimensions of $dimensions" } sprites[spriteName] = DataSprite(spriteName, Vector4i(begin.x + x * size.x, begin.y + y * size.y, begin.x + (x + 1) * size.x, begin.y + (y + 1) * size.y)) } } } if ("frameList" in read) { for ((spriteName, coords) in read.getObject("frameList").entrySet()) { sprites[spriteName] = DataSprite(spriteName, vectors.fromJsonTree(coords)) } } val aliases = Object2ObjectArrayMap() for ((k, v) in read.get("aliases") { JsonObject() }.entrySet()) aliases[k] = v.asString var changes = true while (aliases.isNotEmpty() && changes) { changes = false val i = aliases.entries.iterator() for ((k, v) in i) { require(k !in sprites) { "Sprite with name '$k' already defined" } val sprite = sprites[v] ?: continue sprites[k] = sprite.copy(name = k) changes = true i.remove() } } for ((k, v) in aliases.entries) throw JsonSyntaxException("Alias '$k' want to refer to sprite '$v', but it does not exist") return ImmutableList.copyOf(sprites.values) } private fun compute(it: String): Optional> { val find = Starbound.locate("$it.frames") if (!find.exists) { return Optional.empty() } else { return Optional.of(parseFrames(objects.read(JsonReader(find.reader()).also { it.isLenient = true }))) } } private fun getConfig(path: String): List? { var folder = path.substringBefore(':').substringBeforeLast('/') val name = path.substringBefore(':').substringAfterLast('/').substringBefore('.') while (true) { val find = cache.computeIfAbsent("$folder/$name", ::compute).or { cache.computeIfAbsent("$folder/default", ::compute) } if (find.isPresent) return find.get() folder = folder.substringBeforeLast('/') if (folder.isEmpty()) return null } } } }