PaintToolbox.kt
src/jvmMain/kotlin/afield/graphics/paint/PaintToolbox.kt · 175 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.paint
Named painting tools that keep their state between strokes, and can be saved to a file and loaded back: PaintToolbox.
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
class PaintToolbox : AutoCloseable
Tools kept by name between strokes: a brush stays dirty with what it last picked up, a knife keeps its bead, a rag its load, a pencil its wear, a watercolour brush its water and pigment, until they are reloaded, wiped or replaced.
val tools = PaintToolbox()
tools["big filbert"] = canvas.brush(BrushKind.Filbert, 40.0)
tools.brush("big filbert").load(rgb(0.2, 0.3, 0.5))
canvas.stroke(line, tools.brush("big filbert")) // later: the same brush, as that stroke left it
save writes every tool exactly — each bristle's paint, a knife's bead, a rag's soaked faces — and PaintToolbox.load puts them back, so a studio can keep its tools with the canvas across a saved document or a rewound conversation.
class PaintToolbox : AutoCloseable {
private val tools = LinkedHashMap<String, PaintTool>()
val names: List<String> get() = tools.keys.toList()
The tools' names, in the order they were added.
operator fun get(name: String): PaintTool? = tools[name]
The tool called name, or null.
operator fun set(name: String, tool: PaintTool)
Keep tool as name, putting away (closing) whatever had that name.
operator fun set(name: String, tool: PaintTool) {
require(name.isNotBlank()) { "a tool needs a name" }
val old = tools.put(name, tool)
if (old !== tool) (old as? AutoCloseable)?.close()
}
operator fun contains(name: String): Boolean = name in tools
fun remove(name: String)
Put away the tool called name.
fun remove(name: String) {
(tools.remove(name) as? AutoCloseable)?.close()
}
fun brush(name: String): PaintBrush = typed(name)
The brush called name.
fun knife(name: String): PaintKnife = typed(name)
The knife called name.
fun rag(name: String): PaintRag = typed(name)
The rag called name.
fun pencil(name: String): Pencil = typed(name)
The pencil (or chalk) called name.
fun waterBrush(name: String): WaterBrush = typed(name)
The watercolour brush called name.
fun dynamicBrush(name: String): DynamicBrush = typed(name)
The brush of moving hairs called name.
private inline fun <reified T : PaintTool> typed(name: String): T = when (val t = tools[name]) {
null -> throw NoSuchElementException("no tool named \"$name\"" + if (tools.isEmpty()) "" else "; there are: ${tools.keys.joinToString()}")
is T -> t
else -> throw IllegalArgumentException("\"$name\" is a ${kindOf(t)}, not a ${T::class.simpleName?.removePrefix("Paint")?.lowercase()}")
}
val entries: List<Pair<String, PaintTool>> get() = tools.entries.map { it.key to it.value }
Every tool as name to tool.
fun save(file: File) = DataOutputStream(file.outputStream().buffered()).use { write(it) }
Write every tool, exactly, to file.
fun write(out: DataOutputStream)
Write every tool, exactly, to out.
fun write(out: DataOutputStream) {
out.writeInt(MAGIC)
out.writeInt(VERSION)
out.writeInt(tools.size)
for ((name, t) in tools) {
out.writeUTF(name)
when (t) {
is PaintBrush -> { out.writeByte('B'.code); out.writeInt(t.kind.code); bytes(out, t.state()) }
is PaintKnife -> { out.writeByte('K'.code); bytes(out, t.state()) }
is PaintRag -> { out.writeByte('R'.code); bytes(out, t.state()) }
is Pencil -> { out.writeByte('P'.code); out.writeInt(t.medium.code); out.writeDouble(t.softness); out.writeDouble(t.worn) }
is WaterBrush -> { out.writeByte('W'.code); bytes(out, t.state()) }
is DynamicBrush -> { out.writeByte('D'.code); out.writeInt(t.kind.code); bytes(out, t.state()) }
}
}
}
override fun close()
Put every tool away.
override fun close() {
tools.values.forEach { (it as? AutoCloseable)?.close() }
tools.clear()
}
fun describe(canvas: PaintCanvas): String
One line per tool, for an agent or a person to read: what it is, and what it carries on canvas.
fun describe(canvas: PaintCanvas): String = if (tools.isEmpty()) "no tools kept" else tools.entries.joinToString("\n") { (name, t) ->
"$name: " + when (t) {
is PaintBrush -> {
val c = canvas.carried(t)
"${t.kind} brush ${"%.0f".format(t.width)} wide, " +
if (c.color == null) "clean" else "${"%.0f".format(t.fullness * 100)}% loaded with ${hex(c.color)} (${"%.2f".format(c.paintMm3)} mm³)"
}
is PaintKnife -> {
val c = canvas.carried(t)
"knife, " + if (c.color == null) "clean" else "bead ${"%.0f".format(t.fullness * 100)}% of full, ${hex(c.color)}"
}
is PaintRag -> canvas.ragState(t).let { "rag, face ${"%.0f".format(it.load * 100)}% loaded, cloth ${"%.0f".format(it.soaked * 100)}% soaked" + if (it.damp > 0.01) ", damp" else "" }
is Pencil -> "${t.medium.name.lowercase()} (softness ${t.softness}), ${"%.0f".format(t.worn)} mm drawn since sharpened"
is DynamicBrush -> "${t.kind} brush of ${t.hairCount} moving hairs, ${"%.0f".format(t.widthMm)} mm wide, " +
(if (t.watercolour) "watercolour" else "oil") + ", ${"%.0f".format(t.fill * 100)}% full" +
if (t.retainedBend > 0.05) ", hairs set in a bend of ${"%.0f".format(Math.toDegrees(t.retainedBend))}°" else ""
is WaterBrush -> "watercolour brush ${"%.0f".format(t.width)} wide, ${"%.0f".format(t.fill * 100)}% full of water " +
"(${"%.1f".format(t.waterMm3)} of ${"%.1f".format(t.capacityMm3)} mm³)" + if (t.pigmentUg > 0) ", ${"%.1f".format(t.pigmentUg)} µg of pigment" else ", clean"
}
}
companion object
companion object {
private const val MAGIC = 0x46505442 // "FPTB"
private const val VERSION = 1
fun load(file: File): PaintToolbox
A toolbox written by save.
fun load(file: File): PaintToolbox = DataInputStream(file.inputStream().buffered()).use { read(it) }
fun read(input: DataInputStream): PaintToolbox
A toolbox written by write.
fun read(input: DataInputStream): PaintToolbox {
require(input.readInt() == MAGIC) { "not a paint toolbox" }
val v = input.readInt()
require(v == VERSION) { "a paint toolbox of version $v; this Field reads version $VERSION" }
val box = PaintToolbox()
repeat(input.readInt()) {
val name = input.readUTF()
box.tools[name] = when (val type = input.readByte().toInt().toChar()) {
'B' -> { val kind = input.readInt(); PaintBrush.restore(bytes(input), BrushKind.entries.first { it.code == kind }) }
'K' -> PaintKnife.restore(bytes(input))
'R' -> PaintRag.restore(bytes(input))
'W' -> WaterBrush.restore(bytes(input))
'D' -> { val kind = input.readInt(); DynamicBrush.restore(bytes(input), BrushKind.entries.first { it.code == kind }) }
'P' -> {
val medium = input.readInt()
Pencil(DrawingMedium.entries.first { it.code == medium }, input.readDouble()).also { it.worn = input.readDouble() }
}
else -> throw IllegalArgumentException("unknown tool type '$type' in a paint toolbox")
}
}
return box
}
private fun bytes(out: DataOutputStream, b: ByteArray) { out.writeInt(b.size); out.write(b) }
private fun bytes(input: DataInputStream): ByteArray = ByteArray(input.readInt()).also { input.readFully(it) }
private fun kindOf(t: PaintTool) = when (t) {
is PaintBrush -> "brush"
is PaintKnife -> "knife"
is PaintRag -> "rag"
is Pencil -> "pencil"
is WaterBrush -> "watercolour brush"
is DynamicBrush -> "brush of moving hairs"
}
private fun hex(c: afield.math.linalg.Vec4) = "#%02x%02x%02x".format(
(c.x * 255).toInt().coerceIn(0, 255), (c.y * 255).toInt().coerceIn(0, 255), (c.z * 255).toInt().coerceIn(0, 255))
}
}