GeometryReviewSurface.kt
src/jvmMain/kotlin/afield/ui/visualization/GeometryReviewSurface.kt · 127 lines
@file:afield.runtime.completion.CompletionCategory("Workspace")
package afield.ui.visualization
import afield.geometry.path.div
import afield.geometry.path.minus
import afield.geometry.path.plus
import afield.geometry.path.times
import afield.lang.remotejs.JsFun
import afield.geometry.path.length
import afield.graphics.three.ThreeReviewInbox
import afield.document.graph.Box
import afield.app.server.ReadOnlyDocuments
import afield.runtime.lifecycle.Runloop
import afield.document.graph.registerStopHook
import afield.app.server.thisSessionInfo
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.File
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
A persistent annotation inbox for a custom canvas, the same one a Three.js viewer has:
Box.geometryReviewSurface binds it to a box and returns a GeometryReviewSurface.
fun Box.geometryReviewSurface(name: String, revision: String, outputBox: Box = this): GeometryReviewSurface
Give a custom canvas the same persistent annotation inbox as a Three.js viewer.
name identifies this canvas on the box; revision identifies its authored geometry. The canvas
supplies a selection, saved view and optional actual context/crop PNGs in the ordinary geometry
review format. It owns hit testing and semantic feature identities. AI4's geometry review tools
discover this inbox and can read, reply and resolve notes, including their historical pictures.
outputBox identifies the box whose output can be captured (defaults to this source box).
Re-running replaces the binding, while notes remain on the box. Stop/delete unregisters it.
fun Box.geometryReviewSurface(name: String, revision: String, outputBox: Box = this): GeometryReviewSurface =
bindGeometryReviewSurface(this, name, revision, outputBox) { action ->
val invoke = {
val previous = thisSessionInfo.get()
thisSessionInfo.set(inside)
try { action() } finally { thisSessionInfo.set(previous) }
}
if (Runloop.isMainThread) invoke()
else runBlocking { withContext(Runloop.runLoop.main) { invoke() } }
}
class GeometryReviewSurface internal constructor
A custom canvas's review binding; writes dispatch to its Field document's owner thread.
class GeometryReviewSurface internal constructor(
internal val owner: Box, val name: String, val uid: String, internal val outputBox: Box,
private val onOwner: (() -> JsonObject) -> JsonObject,
) : AutoCloseable {
internal val reviewInbox = ThreeReviewInbox(uid).also { it.owner = owner }
private val closed = AtomicBoolean(false)
internal var stopHook: Runnable? = null
fun updateRevision(revision: String)
Invalidate the current selection when the authored geometry changes; retain saved notes.
fun updateRevision(revision: String) {
check(!closed.get()) { "Review surface is closed" }
require(revision.isNotBlank() && revision.length <= 256) { "Supply a bounded geometry revision" }
reviewInbox.update(revision)
}
fun reviews(includeResolved: Boolean = false): JsonObject
Read notes without acknowledging them or changing the human selection.
fun reviews(includeResolved: Boolean = false): JsonObject = onOwner {
requireLive(); reviewInbox.snapshot(includeResolved)
}
fun receive(record: JsonObject, saveNote: Boolean = true): JsonObject
Receive a revision-labelled selection or save a note with its optional context/crop evidence.
fun receive(record: JsonObject, saveNote: Boolean = true): JsonObject = onOwner {
requireLive()
check(!ReadOnlyDocuments.blocksWritesFrom(owner)) { "This document is read-only" }
reviewInbox.receive(record, saveNote)
}
fun updateNote(request: JsonObject): JsonObject
Append a reply or explicitly resolve/reopen, with the current revision and note version.
fun updateNote(request: JsonObject): JsonObject = onOwner {
requireLive()
check(!ReadOnlyDocuments.blocksWritesFrom(owner)) { "This document is read-only" }
reviewInbox.updateNote(request)
}
private fun requireLive() {
check(!closed.get() && (owner.inside.boxes[owner.id] === owner || owner.inside.root === owner)) {
"The review surface's source box is no longer live"
}
}
override fun close()
override fun close() {
if (!closed.compareAndSet(false, true)) return
customGeometryReviewSurfaces.remove(uid, this)
val key = "_geometryReviewSurface:$name"
if (owner.properties[key] === this) owner.properties.remove(key)
stopHook?.run(); stopHook = null
}
}
private val customGeometryReviewSurfaces = ConcurrentHashMap<String, GeometryReviewSurface>()
internal fun bindGeometryReviewSurface(owner: Box, name: String, revision: String, outputBox: Box = owner,
internal fun bindGeometryReviewSurface(owner: Box, name: String, revision: String, outputBox: Box = owner,
onOwner: (() -> JsonObject) -> JsonObject): GeometryReviewSurface {
require(name.matches(Regex("[a-zA-Z0-9_-]{1,80}"))) { "Review surface name must be a simple word" }
require(revision.isNotBlank() && revision.length <= 256) { "Supply a bounded geometry revision" }
check(!ReadOnlyDocuments.blocksWritesFrom(owner)) { "This document is read-only" }
require(outputBox.inside === owner.inside) { "The canvas output box must belong to the same document" }
val key = "_geometryReviewSurface:$name"
val identity = "${File(owner.inside.filename).canonicalPath}\u0000${owner.id}\u0000$name"
val uid = "review-" + UUID.nameUUIDFromBytes(identity.toByteArray(Charsets.UTF_8))
(owner.properties[key] as? GeometryReviewSurface)?.close()
val binding = GeometryReviewSurface(owner, name, uid, outputBox, onOwner)
binding.updateRevision(revision)
check(customGeometryReviewSurfaces.putIfAbsent(uid, binding) == null) { "This review surface is already bound in another live session" }
owner.properties[key] = binding
binding.stopHook = owner.registerStopHook(Runnable { binding.close() })
return binding
}
internal fun geometryReviewSurfacesOwnedBy(boxes: Collection<Box>): List<GeometryReviewSurface>
internal fun geometryReviewSurfacesOwnedBy(boxes: Collection<Box>): List<GeometryReviewSurface> =
customGeometryReviewSurfaces.values.filter { view -> boxes.any { it === view.owner } }.sortedBy { it.uid }
internal fun customGeometryReviewInbox(uid: String): ThreeReviewInbox?
internal fun customGeometryReviewInbox(uid: String): ThreeReviewInbox? = customGeometryReviewSurfaces[uid]
?.takeIf { it.owner.inside.boxes[it.owner.id] === it.owner || it.owner.inside.root === it.owner }?.reviewInbox