ThreeReview.kt
src/jvmMain/kotlin/afield/graphics/three/ThreeReview.kt · 411 lines
package afield.graphics.three
import afield.geometry.path.div
import afield.geometry.path.minus
import afield.geometry.path.plus
import afield.geometry.path.then
import afield.geometry.path.times
import afield.lang.remotejs.JsFun
import afield.ui.visualization.inspect
import afield.geometry.path.length
import afield.geometry.path.trim
import afield.ui.visualization.lines
import afield.ui.visualization.sameFindingOccurrence
import afield.ui.visualization.validateGeometryFinding
import afield.document.graph.Box
import afield.document.graph.METAROOT_ID
import afield.document.persistence.SavingContext
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID
Geometry identity for a three-dimensional view: what a review note is pinned to.
A note survives anything that is only presentation. Callback and session IDs, hover colour, style and label are left out of the digest, so orbiting, relighting and relaunching identical geometry all leave a note attached. Canonical solids keep their double-boundary identity, and geometry displayed alongside them contributes too: a moving ordinary point invalidates a note even when every solid is unchanged.
internal fun threeReviewRevision(payload: ThreeGeometryPayload): String
The SHA-256 a note is filed under, over one streamed JSON string.
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
internal fun threeReviewRevision(payload: ThreeGeometryPayload): String {
val canonicalDigest = payload.inspector?.artifactDigest
val geometryPayload = if (canonicalDigest == null) payload else {
val canonicalParts = payload.inspector.parts.keys
payload.copy(drawables = payload.drawables.filterNot { it.id in canonicalParts })
}
// Do not re-hash the canonical parts' display floats: their exact geometry is already covered,
// and re-framing or duplicating vertices for shading must not retire existing solid-only notes.
if (canonicalDigest != null && geometryPayload.lines == null && geometryPayload.fills == null &&
geometryPayload.points == null && geometryPayload.drawables.isEmpty() &&
geometryPayload.interactive.isEmpty() && geometryPayload.scene == null) return canonicalDigest
val digest = MessageDigest.getInstance("SHA-256")
val text = RevisionText(digest)
text.open(canonicalDigest?.let { "\"canonicalDigest\":" + Json.encodeToString(String.serializer(), it) })
revisionJson.encodeToStream(ThreeGeometryPayload.serializer(), geometryPayload, text)
text.finish()
return digest.digest().joinToString("") { "%02x".format(it) }
}
private val revisionJson = Json { encodeDefaults = true }
The payload keys a revision covers, and the keys it ignores at every depth.
private val REVISION_KEYS = setOf("lines", "fills", "points", "drawables", "interactive", "scene")
private val REVISION_IGNORED = setOf("callbackId", "sessionId", "hoverColor", "style", "label")
The text a revision hashes, fed to digest as kotlinx writes the payload, never held whole.
The revision is a SHA-256 of one JSON string: an object holding canonicalDigest (for solids), then
the payload's REVISION_KEYS, with REVISION_IGNORED dropped from every object at every depth.
Until 2026-10-07 that string was built as it reads — the payload as a JsonElement tree, a second
filtered tree, then toString() — on every ThreeGeometryViewState.update, on the runloop: two
trees and a string the size of the geometry, for a slider that republishes thirty times a second.
This writes the same bytes without the trees or the string. kotlinx's streaming encoder formats
every value exactly as JsonElement.toString() does — shortest Float.toString, the same string
quoting, compact separators, keys in declaration order — so this only has to re-emit its output
with the ignored members removed and the commas mended. The revisions are therefore the ones every
stored note already carries; ThreeReviewRevisionStreamTest holds the two implementations together.
A push parser, because kotlinx writes into an OutputStream: one byte at a time through a small
state machine, with a mute while a dropped member's value goes past.
source — 159 lines, from line 81
private class RevisionText(private val digest: MessageDigest) : java.io.OutputStream() {
private val out = ByteArray(1 shl 16)
private var used = 0
/** One open object or array; [emitted] says whether a comma is due before the next member. */
private class Frame(val isObject: Boolean, val outer: Boolean, var emitted: Boolean)
private val stack = ArrayList<Frame>()
private var outerEmitted = false
private enum class Mode { VALUE, VALUE_OR_CLOSE, KEY_OR_CLOSE, KEY_START, KEY, COLON, STRING, BARE, AFTER, DONE }
private var mode = Mode.VALUE
private var escaped = false
private val key = java.io.ByteArrayOutputStream()
/** While set nothing is written: a dropped member's value is going past. [mutedAt] is its object's depth. */
private var muted = false
private var mutedAt = -1
private fun emit(b: Int) {
if (muted) return
if (used == out.size) flush()
out[used++] = b.toByte()
}
private fun emit(text: String) = text.encodeToByteArray().forEach { emit(it.toInt()) }
override fun flush() {
digest.update(out, 0, used)
used = 0
}
/** `{`, and the member that goes before the payload's own (`canonicalDigest`), if any. */
fun open(first: String?) {
emit('{'.code)
if (first != null) {
emit(first)
outerEmitted = true
}
}
/** `}`, once the payload's own object has ended, and the last bytes to the digest. */
fun finish() {
check(mode == Mode.DONE && stack.isEmpty()) { "revision text: the payload ended part-way through a value" }
emit('}'.code)
flush()
}
override fun write(b: Int) = accept(b and 0xff)
override fun write(b: ByteArray, off: Int, len: Int) {
for (i in off until off + len) accept(b[i].toInt() and 0xff)
}
private fun top() = stack.last()
private fun valueDone() {
if (muted && stack.size == mutedAt) muted = false
mode = if (stack.isEmpty()) Mode.DONE else Mode.AFTER
}
private fun push(isObject: Boolean) {
val outer = stack.isEmpty()
if (!outer) emit(if (isObject) '{'.code else '['.code)
stack += Frame(isObject, outer, outer && outerEmitted)
mode = if (isObject) Mode.KEY_OR_CLOSE else Mode.VALUE_OR_CLOSE
}
private fun pop() {
val frame = stack.removeAt(stack.size - 1)
if (!frame.outer) emit(if (frame.isObject) '}'.code else ']'.code)
valueDone()
}
/** A value starts: in an array, that is where the comma goes. */
private fun beginValue() {
val frame = stack.lastOrNull() ?: return
if (frame.isObject || muted) return
if (frame.emitted) emit(','.code)
frame.emitted = true
}
private fun keyDone() {
if (muted) return
val name = key.toString(Charsets.UTF_8)
val frame = top()
val keep = name !in REVISION_IGNORED && (!frame.outer || name in REVISION_KEYS)
if (!keep) {
muted = true
mutedAt = stack.size
return
}
if (frame.emitted) emit(','.code)
frame.emitted = true
emit('"'.code)
key.toByteArray().forEach { emit(it.toInt()) }
emit('"'.code)
emit(':'.code)
}
private fun whitespace(b: Int) = b == ' '.code || b == '\n'.code || b == '\r'.code || b == '\t'.code
private fun accept(b: Int) {
var again = true
while (again) {
again = false
when (mode) {
Mode.VALUE -> if (!whitespace(b)) {
beginValue()
when (b) {
'{'.code -> push(true)
'['.code -> push(false)
'"'.code -> { emit(b); escaped = false; mode = Mode.STRING }
else -> { emit(b); mode = Mode.BARE }
}
}
Mode.VALUE_OR_CLOSE -> if (!whitespace(b)) {
if (b == ']'.code) pop() else { mode = Mode.VALUE; again = true }
}
Mode.KEY_OR_CLOSE -> if (!whitespace(b)) {
if (b == '}'.code) pop() else { mode = Mode.KEY_START; again = true }
}
Mode.KEY_START -> if (!whitespace(b)) {
check(b == '"'.code) { "revision text: expected a key, found '${b.toChar()}'" }
key.reset()
escaped = false
mode = Mode.KEY
}
Mode.KEY -> when {
escaped -> { key.write(b); escaped = false }
b == '\\'.code -> { key.write(b); escaped = true }
b == '"'.code -> { keyDone(); mode = Mode.COLON }
else -> key.write(b)
}
Mode.COLON -> if (!whitespace(b)) {
check(b == ':'.code) { "revision text: expected ':', found '${b.toChar()}'" }
mode = Mode.VALUE
}
Mode.STRING -> {
emit(b)
when {
escaped -> escaped = false
b == '\\'.code -> escaped = true
b == '"'.code -> valueDone()
}
}
Mode.BARE -> if (b == ','.code || b == '}'.code || b == ']'.code || whitespace(b)) {
valueDone()
again = true
} else emit(b)
Mode.AFTER -> if (!whitespace(b)) when (b) {
','.code -> mode = if (top().isObject) Mode.KEY_START else Mode.VALUE
'}'.code, ']'.code -> pop()
else -> error("revision text: unexpected '${b.toChar()}' after a value")
}
Mode.DONE -> check(whitespace(b)) { "revision text: '${b.toChar()}' after the payload" }
}
}
}
}
internal class ThreeReviewInbox(private val uid: String)
Browser-to-Field review inbox. Notes are stored on the source box and survive document saves. A new revision invalidates the live selection, while retaining old notes with their original revision.
var owner: Box? = null
var localViewId: String? = null
Local identity lives with the source box, so duplicate/moved documents retain notes without sharing a live viewer. Old unscoped note IDs remain readable without rewriting.
private fun belongs(note: JsonObject): Boolean = note["viewId"]?.jsonPrimitive?.contentOrNull == uid ||
localViewId?.let { local ->
note["localViewId"]?.jsonPrimitive?.contentOrNull == local ||
note["viewId"]?.jsonPrimitive?.contentOrNull == local
} == true
private fun forCurrentView(note: JsonObject): JsonObject =
if (note["viewId"]?.jsonPrimitive?.contentOrNull == uid) note else JsonObject(note + mapOf(
"recordedViewId" to (note["recordedViewId"] ?: note.getValue("viewId")), "viewId" to JsonPrimitive(uid)))
private var revision: String? = null
private var selection: JsonObject? = null
private var notes = listOf<JsonObject>()
private val transientImages by lazy {
// Rebinding a canvas in an ephemeral document must retain the images its notes reference.
val box = owner
if (box == null) java.nio.file.Files.createTempDirectory("field-three-review-")
else synchronized(box) {
box.properties.getOrPut("_geometryReviewImages") {
java.nio.file.Files.createTempDirectory("field-three-review-")
} as java.nio.file.Path
}
}
private fun imageDirectory(): java.nio.file.Path = owner?.takeIf { it.isInsideInitialized() && !it.inside.ephemeral }
?.let { java.nio.file.Path.of(it.inside.filename).resolve(".geometry-review") } ?: transientImages
@Synchronized fun image(hash: String): ByteArray?
@Synchronized fun image(hash: String): ByteArray? = ThreeReviewEvidence.read(hash, storedNotes().filter(::belongs), imageDirectory())
@Synchronized fun update(revision: String)
@Synchronized fun update(revision: String) {
if (this.revision != revision) selection = null
this.revision = revision
}
@Synchronized fun snapshot(includeResolved: Boolean = false): JsonObject
@Synchronized fun snapshot(includeResolved: Boolean = false): JsonObject {
val all = storedNotes().filter(::belongs).map { note ->
JsonObject(mapOf("status" to JsonPrimitive("open"), "version" to JsonPrimitive(0)) + forCurrentView(note) +
("stale" to JsonPrimitive(note["artifactDigest"]?.jsonPrimitive?.contentOrNull != revision)))
}
return buildJsonObject {
put("viewId", uid); put("revision", revision); put("sourceBox", owner?.id)
put("selection", selection ?: JsonNull)
put("notes", JsonArray(all.filter { includeResolved || it["status"]?.jsonPrimitive?.contentOrNull != "resolved" }))
put("openCount", all.count { it["status"]?.jsonPrimitive?.contentOrNull != "resolved" })
put("resolvedCount", all.count { it["status"]?.jsonPrimitive?.contentOrNull == "resolved" })
put("includesResolved", includeResolved)
}
}
fun updateNote(request: JsonObject): JsonObject
Explicit acknowledgement, never a side effect of reading. Preserve the original note and evidence.
body — 57 lines, from line 297
@Synchronized
fun updateNote(request: JsonObject): JsonObject {
require(request.toString().length <= 32_000) { "Review update is too large" }
require(owner?.takeIf { it.isInsideInitialized() }?.inside?.readOnly != true) { "Document is read-only" }
val id = request["id"]?.jsonPrimitive?.contentOrNull ?: error("Note id is required")
val status = request["status"]?.jsonPrimitive?.contentOrNull
val reply = request["reply"]?.jsonPrimitive?.contentOrNull?.trim()
require((reply != null && status == null) || (reply == null && status in setOf("open", "resolved"))) { "Supply a reply or an open/resolved status, not both" }
require(reply == null || (reply.isNotEmpty() && reply.length <= 16_000)) { "Reply must contain between 1 and 16000 characters" }
val summary = request["summary"]?.jsonPrimitive?.contentOrNull.orEmpty().trim()
val actor = request["actor"]?.jsonPrimitive?.contentOrNull.orEmpty().trim()
require(reply != null || (summary.isNotEmpty() && summary.length <= 8_000)) { "A short resolution or reopening explanation is required" }
require(actor.isNotEmpty() && actor.length <= 200) { "Identify the person or agent making this update" }
check(revision != null && request["checkedRevision"]?.jsonPrimitive?.contentOrNull == revision) { "Geometry changed; inspect the current revision before updating the note" }
val previous = storedNotes()
val index = previous.indexOfFirst { it["id"]?.jsonPrimitive?.contentOrNull == id && belongs(it) }
require(index >= 0) { "Unknown note in this view" }
val old = previous[index]
val version = old["version"]?.jsonPrimitive?.intOrNull ?: 0
check(request["expectedVersion"]?.jsonPrimitive?.intOrNull == version) { "Note changed; read the inbox again before updating it" }
val changed = if (reply != null) {
val replies = (old["replies"] as? JsonArray)?.toList().orEmpty()
require(replies.size < 200) { "Reply history is full" }
val entry = buildJsonObject {
put("id", UUID.randomUUID().toString()); put("text", reply); put("actor", actor)
put("checkedRevision", revision); put("at", Instant.now().toString()); put("version", version + 1)
}
JsonObject(old + mapOf("version" to JsonPrimitive(version + 1), "replies" to JsonArray(replies + entry)))
} else {
val history = (old["history"] as? JsonArray)?.toList().orEmpty()
require(history.size < 200) { "Note history is full" }
val event = buildJsonObject {
put("status", status); put("summary", summary); put("actor", actor)
put("checkedRevision", revision); put("at", Instant.now().toString()); put("version", version + 1)
}
JsonObject(old + mapOf("status" to JsonPrimitive(status), "version" to JsonPrimitive(version + 1),
"resolution" to event, "history" to JsonArray(history + event)))
}
val updated = previous.toMutableList().also { it[index] = changed }
require(JsonArray(updated).toString().length <= 2_000_000) { "Review inbox is full" }
persist(updated)
return buildJsonObject { put("ok", true); put("note", forCurrentView(changed)) }
}
private fun persist(updated: List<JsonObject>) {
notes = updated
owner?.properties?.set("geometryReviews", JsonArray(notes).toString())
val box = owner
if (box != null && box.isInsideInitialized() && !box.inside.ephemeral) {
val info = box.inside
SavingContext().save(info.filename, (info.boxes.values.filter { it.id != METAROOT_ID } + info.root).toSet(), "Geometry review")
}
}
private fun storedNotes(): List<JsonObject> = owner?.getPropertyRaw("geometryReviews")?.let { raw ->
runCatching { Json.parseToJsonElement(raw.toString()).jsonArray.map { it.jsonObject } }.getOrNull()
} ?: notes
fun receive(incoming: JsonObject, note: Boolean): JsonObject
Called on Field's main thread. Returns only after validation; stale reviews cannot mutate it.
body — 55 lines, from line 356
@Synchronized
fun receive(incoming: JsonObject, note: Boolean): JsonObject {
require(incoming.toString().length <= 5_500_000) { "Review with images is too large" }
val evidence = incoming["evidence"] as? JsonObject
val record = JsonObject(incoming.filterKeys { it !in setOf("evidence", "status", "version", "resolution", "history", "replies", "localViewId", "recordedViewId") })
require(record.toString().length <= 128_000) { "Review is too large" }
require(record["viewId"]?.jsonPrimitive?.contentOrNull == uid) { "Review view differs" }
require(revision != null && record["artifactDigest"]?.jsonPrimitive?.contentOrNull == revision) { "Geometry changed; select the feature again" }
val view = record["view"]?.jsonObject ?: error("Review has no saved view")
require(view["artifactDigest"]?.jsonPrimitive?.contentOrNull == revision) { "Saved view has another revision" }
require(record["selection"] == view["selection"]) { "Selection and saved view disagree" }
val feature = record["selection"]?.takeUnless { it == JsonNull }?.jsonObject
if (feature != null) {
require(feature["artifactDigest"]?.jsonPrimitive?.contentOrNull == revision) { "Feature has another revision" }
require(feature["kind"]?.jsonPrimitive?.contentOrNull in setOf("point", "vertex", "edge", "face", "region")) { "Unknown feature kind" }
val point = feature["point"]?.jsonArray ?: error("Feature has no point")
require(point.size == 3 && point.all { it.jsonPrimitive.doubleOrNull?.isFinite() == true }) { "Invalid feature point" }
// A feature picked on a contact sheet names its panel.
feature["panel"]?.let { require(it.jsonPrimitive.intOrNull?.let { panel -> panel >= 0 } == true) { "Invalid feature panel" } }
}
if (!note) { selection = record.takeIf { feature != null }; return buildJsonObject { put("ok", true) } }
require(feature != null) { "Pick a feature before saving a note" }
val text = record["text"]?.jsonPrimitive?.contentOrNull ?: error("Note has no text")
require(text.length <= 16_000) { "Note is too long" }
require(owner?.takeIf { it.isInsideInitialized() }?.inside?.readOnly != true) { "Document is read-only" }
// Validate all note constraints before writing attachments.
val previous = storedNotes()
val finding = record["finding"]?.let {
require(it is JsonObject) { "Finding metadata must be an object" }
validateGeometryFinding(it)
it
}
if (finding != null) {
val prior = previous.firstOrNull { old ->
belongs(old) && old["artifactDigest"] == record["artifactDigest"] &&
(old["finding"] as? JsonObject)?.let { sameFindingOccurrence(it, finding) } == true
}
if (prior != null) {
require(prior["finding"] == finding && prior["text"] == record["text"] && prior["selection"] == feature) {
"This finding was already recorded with different evidence. Reply to its thread or use the new run identity."
}
return buildJsonObject { put("ok", true); put("id", prior.getValue("id")); put("existing", true); put("message", "Finding already recorded; its discussion and evidence are unchanged.") }
}
}
require(previous.sumOf { it.toString().length } + record.toString().length + 2000 <= 2_000_000) { "Review inbox is full; export notes before adding more" }
val attachments = evidence?.let { ThreeReviewEvidence.store(it, record, imageDirectory()) }
val saved = JsonObject(record + (localViewId?.let { mapOf("localViewId" to JsonPrimitive(it)) } ?: emptyMap()) +
(attachments?.let { mapOf("evidence" to it) } ?: emptyMap()) + mapOf("id" to JsonPrimitive(UUID.randomUUID().toString()),
"sourceBox" to (owner?.id?.let(::JsonPrimitive) ?: JsonNull), "createdAt" to JsonPrimitive(Instant.now().toString()),
"status" to JsonPrimitive("open"), "version" to JsonPrimitive(0)))
persist(previous + saved)
val box = owner
return buildJsonObject { put("ok", true); put("id", saved.getValue("id")); put("message", if (box?.isInsideInitialized() == true && !box.inside.ephemeral) "Note added to Field." else "Note added to Field's live review inbox.") }
}
}