KStarbound/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt

424 lines
13 KiB
Kotlin

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<DataSprite>?
) {
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<String, Sprite>()
private var dataRef: WeakReference<ByteBuffer>? = 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<String, Sprite> = 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<Vector2i> {
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<Vector2i> {
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<Vector2i>()
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<String, Optional<List<DataSprite>>>()
private val imageCache = ConcurrentHashMap<String, Optional<Image>>()
private val logger = LogManager.getLogger()
private val cleaner = Cleaner.create { Thread(it, "STB Image Cleaner") }
private val dataCache: Cache<String, ByteBuffer> = Caffeine.newBuilder()
.softValues()
.expireAfterAccess(Duration.ofMinutes(20))
.weigher<String, ByteBuffer> { 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<DataSprite> {
val begin = read.get("begin", vectors2) { Vector2i.ZERO }
val sprites = LinkedHashMap<String, DataSprite>()
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<String, String>()
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<List<DataSprite>> {
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<DataSprite>? {
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
}
}
}
}