RectslBoxTools.kt
src/jvmMain/kotlin/afield/lang/rectsl/RectslBoxTools.kt · 109 lines
package afield.lang.rectsl
import afield.document.graph.Box
import afield.ai.tools.aiTool
import afield.graphics.vulkan.Renderer
import afield.document.context.fromContext
import kotlinx.coroutines.withContext
import java.nio.file.Files
import java.nio.file.Path
import java.util.Base64
import kotlin.math.roundToInt
Two tools a document can hand to its AI4 agents: one syntax-checks a RectSL program, the other renders it in the document's live Field 4 window and returns the captured frames to the conversation.
Both are built with aiTool. A box registers the pair once in its code, and running that
again replaces them:
registerRectslStudioTools()
The agent then sees rectsl.check and rectsl.run. {{ … }} escapes in the RectSL source are
compiled against this box's Kotlin engine, so they can read the box's live state (ki-vars,
thisBox members).
fun Box.registerRectslStudioTools()
Registers rectsl.check and rectsl.run against this box, replacing either if it already has them.
fun Box.registerRectslStudioTools() {
aiTool("rectsl.check") {
describe("Compile a RectSL program and report syntax/compile diagnostics, without rendering.")
val source by param<String>("The RectSL program text")
action {
val result = RectAnimationOracle.compile(source())
if (result.ok) mapOf("ok" to true, "message" to "RectSL compiles cleanly.")
else toolError(result.diagnostics)
}
}
aiTool("rectsl.run") {
describe(
"Compile and render a RectSL program in this document's Field 4 window, capturing " +
"frames as images returned to the conversation. {{ }} escapes may reference this box's state."
)
val source by param<String>("The RectSL program text")
val pixelsPerMeter by param<Double>("output resolution in pixels per wall-metre", default = 100.0)
val fps by param<Double>("frames per second to sample", default = 8.0)
val maxImages by param<Int>("maximum number of frames to return", default = 6)
action {
val check = RectAnimationOracle.compile(source())
if (!check.ok) return@action toolError(check.diagnostics)
val frames = try {
thisBox.renderRectslToImages(source(), pixelsPerMeter(), fps(), maxImages())
} catch (t: Throwable) {
return@action toolError("Render failed: ${t.message ?: t::class.java.simpleName}")
}
if (frames.isEmpty()) toolError("Render produced no frames.")
else images(frames, "RectSL render — ${frames.size} frame(s)")
}
}
}
suspend fun Box.renderRectslToImages
Render source in renderer (the document's live renderer by default) and return the captured
frames as data:image/jpeg;base64,… URLs, subsampled to at most maxImages.
{{ … }} escapes are prewarmed on this box's engine, off the runloop (see
prewarmRectSLEscapes) — so they can reference the box's live state and the non-reentrant
graphics runloop never has to compile Kotlin mid-render. The compiled SceneProgram (with those
escapes bound) is then handed to RectSLRenderRunner, not the raw source.
suspend fun Box.renderRectslToImages(
source: String,
pixelsPerMeter: Double = 100.0,
fps: Double = 8.0,
maxImages: Int = 6,
renderer: Renderer = fromContext(),
configure: RectSLRenderSettings.() -> Unit = {}
): List<String> {
val precompiled = withContext(ki.compilationDispatcher) { prewarmRectSLEscapes(source, ki) }
val compiled = compileRectSL(source, engine = ki, precompiledEscapes = precompiled)
val program = compiled.programOrNull
?: throw RectLangException("RectSL compile failed:\n" + compiled.diagnostics.joinToString("\n"))
val dir = Files.createTempDirectory("rectsl-tool-$id")
val settings = RectSLRenderSettings(
pixelsPerMeter = pixelsPerMeter,
fps = fps,
outputDirectory = dir,
renderer = renderer,
).apply {
includeFinalFrame = true
}.apply(configure)
val result = RectSLRenderRunner.renderJpgSequence(program, settings)
return subsampleEvenly(result.files, maxImages).map { path ->
"data:image/jpeg;base64," + Base64.getEncoder().encodeToString(Files.readAllBytes(path))
}
}
Pick at most max items spread evenly across items (always including first and last).
private fun <T> subsampleEvenly(items: List<T>, max: Int): List<T> {
if (max <= 0 || items.size <= max) return items
val step = (items.size - 1).toDouble() / (max - 1).toDouble()
return (0 until max)
.map { items[(it * step).roundToInt().coerceIn(0, items.size - 1)] }
.distinct()
}