Add SSD review OCR and native vision bridge

ober

baecde6edf51a3b2998c31d3c8ed5303245c6d87

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index a4158d2..91f23fb 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -256,10 +256,12 @@
        "import android.app.AlertDialog"
        "import android.content.ContentUris"
        "import android.content.Intent"
-       "import android.content.pm.PackageManager"
-       "import android.graphics.Bitmap"
-       "import android.graphics.Matrix"
-       "import android.graphics.pdf.PdfRenderer"
+	       "import android.content.pm.PackageManager"
+	       "import android.graphics.Bitmap"
+	       "import android.graphics.Color"
+	       "import android.graphics.Matrix"
+	       "import android.graphics.drawable.GradientDrawable"
+	       "import android.graphics.pdf.PdfRenderer"
        "import android.net.Uri"
        "import android.os.Build"
        "import android.os.Bundle"
@@ -282,9 +284,10 @@
        "import android.widget.LinearLayout"
        "import android.widget.ScrollView"
        "import android.widget.TextView"
-       "import android.widget.Toast"
-       "import androidx.documentfile.provider.DocumentFile"
-       "import java.io.File"
+	       "import android.widget.Toast"
+	       "import androidx.documentfile.provider.DocumentFile"
+	       "import org.json.JSONObject"
+	       "import java.io.File"
        "import java.util.UUID"
        "import kotlin.concurrent.thread"
        "import kotlin.math.ceil"
@@ -302,10 +305,11 @@
        "    private var pageIndex: Int = 0"
        "    private var pageCount: Int = 0"
        "    private var currentBitmap: Bitmap? = null"
-       "    private var currentSession: SsdSession? = null"
-       "    private var selectedGroupId: String? = null"
-       "    private var appendMode: Boolean = false"
-       "    private var reopenSsdBrowserOnResume: Boolean = false"
+	       "    private var currentSession: SsdSession? = null"
+	       "    private var selectedGroupId: String? = null"
+	       "    private var appendMode: Boolean = false"
+	       "    private var ssdAreaMode: Boolean = false"
+	       "    private var reopenSsdBrowserOnResume: Boolean = false"
        "    @Volatile private var loadGeneration: Int = 0"
        "    private val boxTypeDisplays: List<String> by lazy { BoxTypes.load(this).map { it.display } }"
        "    private val firingArcDisplays = listOf("
@@ -359,11 +363,11 @@
        "            setPadding(8, 8, 8, 10)"
        "        }"
        "        root.addView(status)"
-       "        reviewView = SsdReviewView(this).apply {"
-       "            onStatus = { setStatus(it) }"
-       "            onAreaSelected = { rect, ids -> post { showApproveDialog(rect, ids) } }"
-       "            onGroupTapped = { selectGroup(it) }"
-       "        }"
+	       "        reviewView = SsdReviewView(this).apply {"
+	       "            onStatus = { setStatus(it) }"
+	       "            onAreaSelected = { rect, ids -> handleAreaSelected(rect, ids) }"
+	       "            onGroupTapped = { selectGroup(it) }"
+	       "        }"
        "        root.addView(reviewView, LinearLayout.LayoutParams("
        "            LinearLayout.LayoutParams.MATCH_PARENT,"
        "            0,"
@@ -372,42 +376,41 @@
        "        val controls = LinearLayout(this).apply {"
        "            orientation = LinearLayout.VERTICAL"
        "            addView(actionRow("
-       "                button(\"Open SSDs\") { openPdf() }.apply { textSize = 18f },"
-       "                button(\"Open Any\") { openAnyPdf() },"
-       "                button(\"Detect\") { detectCurrent(forceDetect = true) },"
-       "                button(\"Rotate\") { rotatePage() },"
-       "            ))"
-       "            addView(actionRow("
-       "                button(\"Prev Page\") { changePage(-1) },"
-       "                button(\"Page #\") { showPageDialog() },"
-       "                button(\"Next Page\") { changePage(1) },"
-       "            ))"
-       "            addView(actionRow("
-       "                button(\"Pan\") { setSelectMode(false) },"
-       "                button(\"Select Area\") { setSelectMode(true) },"
-       "                button(\"Fit\") { reviewView.fitImage() },"
-       "            ))"
-       "            addView(actionRow("
-       "                button(\"Prev Group\") { selectRelativeGroup(-1) },"
-       "                button(\"Next Group\") { selectRelativeGroup(1) },"
-       "                button(\"Edit\") { editSelectedGroup() },"
-       "            ))"
-       "            addView(actionRow("
-       "                button(\"Append\") { startAppendMode() },"
-       "                button(\"Approve\") { approveSelectedGroup() },"
-       "                button(\"Delete\") { deleteSelectedGroup() },"
-       "                button(\"Save\") { saveTruth() },"
-       "            ))"
+	       "                button(\"Open SSDs\", ButtonKind.PRIMARY) { openPdf() },"
+	       "                button(\"Detect\", ButtonKind.ACCENT) { detectCurrent(forceDetect = true) },"
+	       "                button(\"Set SSD Area\", ButtonKind.MODE) { startSsdAreaMode() },"
+	       "            ))"
+	       "            addView(actionRow("
+	       "                button(\"Open Any\") { openAnyPdf() },"
+	       "                button(\"Rotate\") { rotatePage() },"
+	       "                button(\"Fit\") { reviewView.fitImage() },"
+	       "            ))"
+	       "            addView(actionRow("
+	       "                button(\"Prev Page\") { changePage(-1) },"
+	       "                button(\"Page #\") { showPageDialog() },"
+	       "                button(\"Next Page\") { changePage(1) },"
+	       "            ))"
+	       "            addView(actionRow("
+	       "                button(\"Pan\", ButtonKind.NEUTRAL) { setSelectMode(false) },"
+	       "                button(\"Select Area\", ButtonKind.PRIMARY) { setSelectMode(true) },"
+	       "                button(\"Add\", ButtonKind.ACCENT) { startAppendMode() },"
+	       "            ))"
+	       "            addView(actionRow("
+	       "                button(\"Prev\", ButtonKind.NAV) { selectRelativeGroup(-1) },"
+	       "                button(\"Next\", ButtonKind.PRIMARY) { selectRelativeGroup(1) },"
+	       "                button(\"Edit\") { editSelectedGroup() },"
+	       "            ))"
+	       "            addView(actionRow("
+	       "                button(\"Approve\", ButtonKind.ACCENT) { approveSelectedGroup() },"
+	       "                button(\"Delete\", ButtonKind.DANGER) { deleteSelectedGroup() },"
+	       "                button(\"Save\") { saveTruth() },"
+	       "            ))"
        "            addView(actionRow("
        "                button(\"Pull Truth\") { pullTruthDump() },"
        "                button(\"Push Truth\") { pushTruthDump() },"
        "                button(\"Sync Folder\") { syncTruthFolder() },"
-       "            ))"
-       "            addView(actionRow("
-       "                button(\"Export Zip\") { exportTruthZip() },"
-       "                button(\"Import Zip\") { importTruthZip() },"
-       "            ))"
-       "        }"
+	       "            ))"
+	       "        }"
        "        root.addView(ScrollView(this).apply {"
        "            isFillViewport = false"
        "            addView(controls)"
@@ -418,22 +421,42 @@
        "        return root"
        "    }"
        ""
-       "    private fun button(label: String, action: () -> Unit): Button ="
-       "        Button(this).apply {"
-       "            text = label"
-       "            minHeight = 56"
-       "            setAllCaps(false)"
-       "            setOnClickListener { action() }"
-       "        }"
+	       "    private enum class ButtonKind(val backgroundColor: Int, val foregroundColor: Int, val textSp: Float, val heightDp: Int) {"
+	       "        PRIMARY(Color.rgb(37, 99, 235), Color.WHITE, 18f, 56),"
+	       "        ACCENT(Color.rgb(5, 150, 105), Color.WHITE, 18f, 56),"
+	       "        MODE(Color.rgb(245, 158, 11), Color.rgb(15, 23, 42), 17f, 54),"
+	       "        NAV(Color.rgb(51, 65, 85), Color.WHITE, 17f, 52),"
+	       "        NEUTRAL(Color.rgb(226, 232, 240), Color.rgb(15, 23, 42), 15f, 48),"
+	       "        DANGER(Color.rgb(185, 28, 28), Color.WHITE, 16f, 50)"
+	       "    }"
+	       ""
+	       "    private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()"
+	       ""
+	       "    private fun button(label: String, kind: ButtonKind = ButtonKind.NEUTRAL, action: () -> Unit): Button ="
+	       "        Button(this).apply {"
+	       "            text = label"
+	       "            textSize = kind.textSp"
+	       "            minHeight = dp(kind.heightDp)"
+	       "            setTextColor(kind.foregroundColor)"
+	       "            setAllCaps(false)"
+	       "            setPadding(dp(10), dp(6), dp(10), dp(6))"
+	       "            background = GradientDrawable().apply {"
+	       "                cornerRadius = dp(9).toFloat()"
+	       "                setColor(kind.backgroundColor)"
+	       "            }"
+	       "            setOnClickListener { action() }"
+	       "        }"
        ""
        "    private fun actionRow(vararg buttons: Button): LinearLayout ="
        "        LinearLayout(this).apply {"
        "            orientation = LinearLayout.HORIZONTAL"
        "            gravity = Gravity.CENTER_VERTICAL"
-       "            buttons.forEach { item ->"
-       "                addView(item, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))"
-       "            }"
-       "        }"
+	       "            buttons.forEach { item ->"
+	       "                val params = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)"
+	       "                params.setMargins(dp(3), dp(3), dp(3), dp(3))"
+	       "                addView(item, params)"
+	       "            }"
+	       "        }"
        ""
        "    private fun boxTypeInput(): AutoCompleteTextView ="
        "        AutoCompleteTextView(this).apply {"
@@ -635,22 +658,31 @@
        "        setSelectMode(!reviewView.selectMode)"
        "    }"
        ""
-       "    private fun setSelectMode(enabled: Boolean) {"
-       "        if (!enabled) appendMode = false"
-       "        reviewView.selectMode = enabled"
-       "        setStatus("
-       "            when {"
-       "                appendMode -> \"Append mode: drag boxes to add to the selected group\""
-       "                reviewView.selectMode -> \"Select mode: drag a loose box around SSD boxes\""
+	       "    private fun setSelectMode(enabled: Boolean) {"
+	       "        if (!enabled) {"
+	       "            appendMode = false"
+	       "            ssdAreaMode = false"
+	       "            reviewView.snapSelectionToCells = true"
+	       "        } else if (!ssdAreaMode) {"
+	       "            reviewView.snapSelectionToCells = true"
+	       "        }"
+	       "        reviewView.selectMode = enabled"
+	       "        setStatus("
+	       "            when {"
+	       "                ssdAreaMode -> \"SSD area mode: drag around the ship SSD; tags outside that area will be removed\""
+	       "                appendMode -> \"Append mode: drag boxes to add to the selected group\""
+	       "                reviewView.selectMode -> \"Select mode: drag a loose box around SSD boxes\""
        "                else -> \"Pan mode\""
        "            }"
        "        )"
        "    }"
        ""
-       "    private fun finishSelectInteraction() {"
-       "        appendMode = false"
-       "        reviewView.clearInteractionState()"
-       "    }"
+	       "    private fun finishSelectInteraction() {"
+	       "        appendMode = false"
+	       "        ssdAreaMode = false"
+	       "        reviewView.snapSelectionToCells = true"
+	       "        reviewView.clearInteractionState()"
+	       "    }"
        ""
        "    private fun restoreViewportAfterLayout(viewport: SsdReviewView.ViewportState?) {"
        "        reviewView.restoreViewport(viewport)"
@@ -666,12 +698,25 @@
        "            reviewView.selectMode = false"
        "            setStatus(\"Select an existing group first, then tap Append\")"
        "            return"
-       "        }"
-       "        appendMode = true"
-       "        reviewView.selectMode = true"
-       "        reviewView.selectedGroupId = group.id"
-       "        setStatus(\"Append mode: drag boxes to add to ${group.label.ifBlank { group.id }}\")"
-       "    }"
+	       "        }"
+	       "        appendMode = true"
+	       "        ssdAreaMode = false"
+	       "        reviewView.snapSelectionToCells = true"
+	       "        reviewView.selectMode = true"
+	       "        reviewView.selectedGroupId = group.id"
+	       "        setStatus(\"Append mode: drag boxes to add to ${group.label.ifBlank { group.id }}\")"
+	       "    }"
+	       ""
+	       "    private fun startSsdAreaMode() {"
+	       "        currentSession ?: return"
+	       "        appendMode = false"
+	       "        ssdAreaMode = true"
+	       "        reviewView.snapSelectionToCells = false"
+	       "        reviewView.selectMode = true"
+	       "        reviewView.selectedGroupId = selectedGroupId"
+	       "        reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)"
+	       "        setStatus(\"SSD area mode: drag around the ship SSD; tags outside that area will be removed\")"
+	       "    }"
        ""
        "    private fun rotatePage() {"
        "        reviewView.rotateClockwise()"
@@ -694,15 +739,44 @@
        "                pageIndex = page - 1"
        "                renderCurrentPage()"
        "            }"
-       "            .show()"
-       "    }"
-       ""
-       "    private fun selectGroup(groupId: String?) {"
+	       "            .show()"
+	       "    }"
+	       ""
+	       "    private fun applySsdArea(rect: FloatArray) {"
+	       "        val session = currentSession ?: return"
+	       "        val area = floatArrayOf("
+	       "            rect[0].coerceIn(0f, session.imageWidth.toFloat()),"
+	       "            rect[1].coerceIn(0f, session.imageHeight.toFloat()),"
+	       "            rect[2].coerceIn(0f, session.imageWidth.toFloat()),"
+	       "            rect[3].coerceIn(0f, session.imageHeight.toFloat())"
+	       "        )"
+	       "        if ((area[2] - area[0]) < 10f || (area[3] - area[1]) < 10f) {"
+	       "            finishSelectInteraction()"
+	       "            setStatus(\"SSD area ignored: selection was too small\")"
+	       "            return"
+	       "        }"
+	       "        val removed = session.setSsdArea(area)"
+	       "        selectedGroupId = session.groups.firstOrNull { it.id == selectedGroupId }?.id"
+	       "            ?: session.groups.firstOrNull()?.id"
+	       "        reviewView.ssdArea = session.ssdArea"
+	       "        reviewView.selectedGroupId = selectedGroupId"
+	       "        truthStore.appendEvent(\"ssd_area_set\", session, JSONObject().put(\"removed_groups\", removed))"
+	       "        reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)"
+	       "        reviewView.invalidate()"
+	       "        saveSessionAsync("
+	       "            session,"
+	       "            \"SSD area set; removed $removed outside tags; saving...\","
+	       "            \"SSD area saved; removed $removed outside tags\""
+	       "        )"
+	       "    }"
+	       ""
+	       "    private fun selectGroup(groupId: String?) {"
        "        val session = currentSession ?: return"
        "        val group = session.groups.firstOrNull { it.id == groupId } ?: return"
-       "        selectedGroupId = group.id"
-       "        reviewView.selectedGroupId = group.id"
-       "        reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)"
+	       "        selectedGroupId = group.id"
+	       "        reviewView.selectedGroupId = group.id"
+	       "        reviewView.centerOnGroup(group)"
+	       "        reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)"
        "        val arc = if (group.firingArc.isNotBlank()) \", arc ${group.firingArc}\" else \"\""
        "        setStatus(\"${group.id}: ${group.label.ifBlank { \"unlabeled\" }} (${group.count} boxes$arc, ${group.status})\")"
        "    }"
@@ -760,10 +834,11 @@
        "                    currentBitmap = bitmap"
        "                    currentSession = null"
        "                    selectedGroupId = null"
-       "                    reviewView.bitmap = bitmap"
-       "                    reviewView.session = null"
-       "                    reviewView.selectedGroupId = null"
-       "                    setStatus(\"Rendered $renderPdfName page ${renderPageIndex + 1}/$renderPageCount; loading truth\")"
+	       "                    reviewView.bitmap = bitmap"
+	       "                    reviewView.session = null"
+	       "                    reviewView.selectedGroupId = null"
+	       "                    reviewView.ssdArea = null"
+	       "                    setStatus(\"Rendered $renderPdfName page ${renderPageIndex + 1}/$renderPageCount; loading truth\")"
        "                    detectCurrent("
        "                        expectedGeneration = renderGeneration,"
        "                        expectedPageIndex = renderPageIndex,"
@@ -814,16 +889,18 @@
        "                )"
        ""
        "                val localTruth = if (forceDetect) null else truthStore.loadTruth(session)"
-       "                val localGroupCount = localTruth?.optJSONArray(\"groups\")?.length() ?: 0"
-       "                if (localGroupCount >= FAST_TRUTH_GROUP_THRESHOLD) {"
-       "                    truthStore.applyTruth(session)"
-       "                    runOnUiThread {"
+	       "                val localGroupCount = localTruth?.optJSONArray(\"groups\")?.length() ?: 0"
+	       "                if (localGroupCount >= FAST_TRUTH_GROUP_THRESHOLD) {"
+	       "                    truthStore.applyTruth(session)"
+	       "                    session.pruneGroupsOutsideSsdArea()"
+	       "                    runOnUiThread {"
        "                        if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread"
        "                        currentSession = session"
        "                        selectedGroupId = null"
-       "                        reviewView.session = session"
-       "                        reviewView.selectedGroupId = null"
-       "                        setStatus(\"${session.cells.size} cells, ${session.groups.size} groups, replayed cached truth\")"
+	       "                        reviewView.session = session"
+	       "                        reviewView.selectedGroupId = null"
+	       "                        reviewView.ssdArea = session.ssdArea"
+	       "                        setStatus(\"${session.cells.size} cells, ${session.groups.size} groups, replayed cached truth\")"
        "                    }"
        "                    truthStore.pullRemoteTruth(session)"
        "                    return@thread"
@@ -831,31 +908,49 @@
        ""
        "                val remoteMatches = truthStore.pullRemoteTruth(session)"
        "                val existingTruth = truthStore.loadTruth(session)"
-       ""
-       "                if (existingTruth != null) {"
-       "                    if (forceDetect) {"
+       "                var ocrGuesses = 0"
+       "                var detectorNote = \"\""
+       ""
+	       "                if (existingTruth != null) {"
+	       "                    session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))"
+	       "                    if (forceDetect) {"
+       "                        val forcedDetection = SsdVisionBridge.detect(bitmap)"
+       "                        if (forcedDetection != null) detectorNote = \", rust detector\""
+       "                        val forcedCells = forcedDetection?.cells ?: SsdDetector.detectCells(bitmap)"
+       "                        session.cells.addAll(forcedCells.filter { session.cellInsideSsdArea(it) })"
+	       "                    }"
+	       "                    truthStore.applyTruth(session)"
+	       "                    session.pruneGroupsOutsideSsdArea()"
+       "                } else {"
+       "                    val nativeDetection = SsdVisionBridge.detect(bitmap)"
+       "                    if (nativeDetection != null) {"
+       "                        session.cells.addAll(nativeDetection.cells)"
+       "                        session.groups.addAll(nativeDetection.groups.take(80))"
+       "                        detectorNote = \", rust detector\""
+       "                    } else {"
        "                        session.cells.addAll(SsdDetector.detectCells(bitmap))"
+       "                        session.groups.addAll(SsdDetector.initialGroups(session.cells).take(80))"
        "                    }"
-       "                    truthStore.applyTruth(session)"
-       "                } else {"
-       "                    session.cells.addAll(SsdDetector.detectCells(bitmap))"
-       "                    session.groups.addAll(SsdDetector.initialGroups(session.cells).take(80))"
        "                    Guessing.applyInitialGuesses(this, session)"
+       "                    session.ocrWords.addAll(SsdOcr.recognizeWords(bitmap))"
+       "                    ocrGuesses = SsdOcr.applyOcrGuesses(this, session)"
        "                }"
        "                val learnedGuesses = if (existingTruth == null) truthStore.applyLearnedGuesses(session) else 0"
        "                runOnUiThread {"
        "                    if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread"
        "                    currentSession = session"
        "                    selectedGroupId = null"
-       "                    reviewView.session = session"
-       "                    reviewView.selectedGroupId = null"
-       "                    val truthNote = if (existingTruth != null) {"
+	       "                    reviewView.session = session"
+	       "                    reviewView.selectedGroupId = null"
+	       "                    reviewView.ssdArea = session.ssdArea"
+	       "                    val truthNote = if (existingTruth != null) {"
        "                        if (remoteMatches > 0) \", pulled remote truth\" else \", replayed truth\""
        "                    } else {"
        "                        if (remoteMatches > 0) \", remote truth unavailable for this render\" else \"\""
        "                    }"
+       "                    val ocrNote = if (ocrGuesses > 0) \", $ocrGuesses OCR guesses\" else if (session.ocrWords.isNotEmpty()) \", OCR ${session.ocrWords.size} words\" else \"\""
        "                    val learnedNote = if (learnedGuesses > 0) \", $learnedGuesses learned guesses\" else \"\""
-       "                    setStatus(\"${session.cells.size} cells, ${session.groups.size} groups$truthNote$learnedNote\")"
+       "                    setStatus(\"${session.cells.size} cells, ${session.groups.size} groups$detectorNote$truthNote$ocrNote$learnedNote\")"
        "                }"
        "            } catch (error: Exception) {"
        "                runOnUiThread {"
@@ -867,14 +962,43 @@
        "        }"
        "    }"
        ""
-       "    private fun isCurrentLoad(generation: Int, uri: Uri, page: Int): Boolean ="
-       "        generation == loadGeneration && pdfUri == uri && pageIndex == page"
-       ""
-       "    private fun showApproveDialog(rect: FloatArray, detectedIds: List<String>) {"
-       "        val session = currentSession ?: return"
-       "        if (appendMode) {"
-       "            showAppendDialog(rect, detectedIds)"
-       "            return"
+	       "    private fun isCurrentLoad(generation: Int, uri: Uri, page: Int): Boolean ="
+	       "        generation == loadGeneration && pdfUri == uri && pageIndex == page"
+	       ""
+	       "    private fun handleAreaSelected(rect: FloatArray, detectedIds: List<String>) {"
+	       "        if (currentSession == null) {"
+	       "            setStatus(\"Selection ignored: truth/detection is still loading\")"
+	       "            return"
+	       "        }"
+	       "        val applyAsSsdArea = ssdAreaMode"
+	       "        val statusText = if (applyAsSsdArea) {"
+	       "            \"SSD area selected; applying boundary...\""
+	       "        } else {"
+	       "            \"Selected ${detectedIds.size} boxes; opening editor...\""
+	       "        }"
+	       "        setStatus(statusText)"
+	       "        reviewView.postDelayed({"
+	       "            if (currentSession == null) {"
+	       "                setStatus(\"Selection ignored: truth/detection is still loading\")"
+	       "                return@postDelayed"
+	       "            }"
+	       "            if (applyAsSsdArea) {"
+	       "                applySsdArea(rect)"
+	       "            } else {"
+	       "                showApproveDialog(rect, detectedIds)"
+	       "            }"
+	       "        }, 80)"
+	       "    }"
+	       ""
+	       "    private fun showApproveDialog(rect: FloatArray, detectedIds: List<String>) {"
+	       "        val session = currentSession"
+	       "        if (session == null) {"
+	       "            setStatus(\"Selection ignored: truth/detection is still loading\")"
+	       "            return"
+	       "        }"
+	       "        if (appendMode) {"
+	       "            showAppendDialog(rect, detectedIds)"
+	       "            return"
        "        }"
        "        val layout = LinearLayout(this).apply {"
        "            orientation = LinearLayout.VERTICAL"
@@ -1207,7 +1331,7 @@
        "                }"
        "            } catch (error: Exception) {"
        "                runOnUiThread {"
-       "                    setStatus(\"Truth pull failed: ${error.message}. Use Import Zip for offline transfer.\")"
+	       "                    setStatus(\"Truth pull failed: ${error.message}. Check Truth Server or use Sync Folder.\")"
        "                }"
        "            }"
        "        }"
@@ -1224,7 +1348,7 @@
        "                }"
        "            } catch (error: Exception) {"
        "                runOnUiThread {"
-       "                    setStatus(\"Truth push failed: ${error.message}. Use Export Zip for offline transfer.\")"
+	       "                    setStatus(\"Truth push failed: ${error.message}. Check Truth Server or use Sync Folder.\")"
        "                }"
        "            }"
        "        }"
@@ -1608,6 +1732,12 @@
        "    }"
        "}"
        ""
+       "data class OcrWord("
+       "    val text: String,"
+       "    val conf: Float,"
+       "    val bbox: FloatArray"
+       ")"
+       ""
        "data class SsdGroup("
        "    val id: String,"
        "    var label: String = \"\","
@@ -1680,12 +1810,14 @@
        "    val pageCount: Int,"
        "    val dpi: Int,"
        "    val imageWidth: Int,"
-       "    val imageHeight: Int,"
-       "    val cells: MutableList<SsdCell> = mutableListOf(),"
-       "    val groups: MutableList<SsdGroup> = mutableListOf(),"
-       "    val suppressedGroups: MutableList<SsdGroup> = mutableListOf()"
-       ") {"
-       "    fun cellsById(): Map<String, SsdCell> = cells.associateBy { it.id }"
+	       "    val imageHeight: Int,"
+	       "    val cells: MutableList<SsdCell> = mutableListOf(),"
+	       "    val groups: MutableList<SsdGroup> = mutableListOf(),"
+	       "    val ocrWords: MutableList<OcrWord> = mutableListOf(),"
+	       "    val suppressedGroups: MutableList<SsdGroup> = mutableListOf(),"
+	       "    var ssdArea: FloatArray? = null"
+	       ") {"
+	       "    fun cellsById(): Map<String, SsdCell> = cells.associateBy { it.id }"
        ""
        "    fun recomputeGroup(group: SsdGroup) {"
        "        val groupCells = group.cellIds.mapNotNull { id -> cells.firstOrNull { it.id == id } }"
@@ -1697,10 +1829,40 @@
        "        val y1 = groupCells.minOf { it.y }"
        "        val x2 = groupCells.maxOf { it.x + it.w }"
        "        val y2 = groupCells.maxOf { it.y + it.h }"
-       "        group.bbox = floatArrayOf(x1, y1, x2, y2)"
-       "    }"
-       ""
-       "    fun nextCellId(): String {"
+	       "        group.bbox = floatArrayOf(x1, y1, x2, y2)"
+	       "    }"
+	       ""
+	       "    fun cellInsideSsdArea(cell: SsdCell): Boolean ="
+	       "        ssdArea?.let { area -> rectCenterInside(cell.rect(), area) } ?: true"
+	       ""
+	       "    fun groupInsideSsdArea(group: SsdGroup): Boolean ="
+	       "        ssdArea?.let { area -> rectCenterInside(group.bbox, area) } ?: true"
+	       ""
+	       "    fun setSsdArea(area: FloatArray): Int {"
+	       "        ssdArea = floatArrayOf("
+	       "            min(area[0], area[2]),"
+	       "            min(area[1], area[3]),"
+	       "            max(area[0], area[2]),"
+	       "            max(area[1], area[3])"
+	       "        )"
+	       "        return pruneGroupsOutsideSsdArea()"
+	       "    }"
+	       ""
+	       "    fun pruneGroupsOutsideSsdArea(): Int {"
+	       "        val area = ssdArea ?: return 0"
+	       "        val outside = groups.filter { group -> !rectCenterInside(group.bbox, area) }.toList()"
+	       "        outside.forEach { group ->"
+	       "            groups.remove(group)"
+	       "            group.status = \"suppressed\""
+	       "            group.notes = if (group.notes.isBlank()) \"Outside SSD area\" else \"${group.notes}; Outside SSD area\""
+	       "            if (suppressedGroups.none { it.id == group.id }) {"
+	       "                suppressedGroups.add(group)"
+	       "            }"
+	       "        }"
+	       "        return outside.size"
+	       "    }"
+	       ""
+	       "    fun nextCellId(): String {"
        "        val maxId = cells.mapNotNull { it.id.removePrefix(\"c\").toIntOrNull() }.maxOrNull() ?: 0"
        "        return \"c%04d\".format(maxId + 1)"
        "    }"
@@ -1720,8 +1882,8 @@
        "        }"
        "        val suppressedJson = JSONArray()"
        "        suppressedGroups.forEach { suppressedJson.put(it.toTruthJson(cellsById)) }"
-       "        return JSONObject()"
-       "            .put(\"schema_version\", 1)"
+	       "        val payload = JSONObject()"
+	       "            .put(\"schema_version\", 1)"
        "            .put(\"generated_at\", System.currentTimeMillis().toString())"
        "            .put(\"session_id\", sessionId)"
        "            .put(\"source_key\", sourceKey)"
@@ -1739,10 +1901,14 @@
        "            .put(\"parser\", JSONObject()"
        "                .put(\"name\", \"SFB SSD Review Android\")"
        "                .put(\"version\", \"android-kotlin-mvp-1\")"
-       "                .put(\"cell_detection\", JSONArray(listOf(\"android-threshold-components\"))))"
-       "            .put(\"groups\", groupsJson)"
-       "            .put(\"suppressed_groups\", suppressedJson)"
-       "    }"
+       "                .put(\"cell_detection\", JSONArray(if (cells.any { it.detector.startsWith(\"rust\") }) listOf(\"jerboa-vision-rust\", \"android-threshold-components\") else listOf(\"android-threshold-components\")))"
+       "                .put(\"ocr\", \"mlkit-text-recognition-latin-bundled\")"
+       "                .put(\"ocr_word_count\", ocrWords.size))"
+	       "            .put(\"groups\", groupsJson)"
+	       "            .put(\"suppressed_groups\", suppressedJson)"
+	       "        ssdArea?.let { payload.put(\"ssd_area\", floatArrayToJson(it)) }"
+	       "        return payload"
+	       "    }"
        "}"
        ""
        "fun bboxForCells(cells: List<SsdCell>): FloatArray {"
@@ -1755,12 +1921,32 @@
        "    )"
        "}"
        ""
-       "fun rectOverlapArea(a: FloatArray, b: FloatArray): Float {"
-       "    val x = max(0f, min(a[2], b[2]) - max(a[0], b[0]))"
-       "    val y = max(0f, min(a[3], b[3]) - max(a[1], b[1]))"
-       "    return x * y"
-       "}"
-       ))
+	       "fun rectOverlapArea(a: FloatArray, b: FloatArray): Float {"
+	       "    val x = max(0f, min(a[2], b[2]) - max(a[0], b[0]))"
+	       "    val y = max(0f, min(a[3], b[3]) - max(a[1], b[1]))"
+	       "    return x * y"
+	       "}"
+	       ""
+	       "fun floatArrayToJson(rect: FloatArray): JSONArray = JSONArray().apply {"
+	       "    rect.forEach { put(it.toDouble()) }"
+	       "}"
+	       ""
+	       "fun jsonRect(json: JSONArray?): FloatArray? {"
+	       "    if (json == null || json.length() < 4) return null"
+	       "    return floatArrayOf("
+	       "        json.optDouble(0, 0.0).toFloat(),"
+	       "        json.optDouble(1, 0.0).toFloat(),"
+	       "        json.optDouble(2, 0.0).toFloat(),"
+	       "        json.optDouble(3, 0.0).toFloat()"
+	       "    )"
+	       "}"
+	       ""
+	       "fun rectCenterInside(rect: FloatArray, area: FloatArray): Boolean {"
+	       "    val cx = (rect[0] + rect[2]) / 2f"
+	       "    val cy = (rect[1] + rect[3]) / 2f"
+	       "    return cx >= area[0] && cx <= area[2] && cy >= area[1] && cy <= area[3]"
+	       "}"
+	       ))
     (kotlin-file-lines "com/sfb/ssdreview/SsdDetector.kt"
       (
        "package com.sfb.ssdreview"
@@ -2176,6 +2362,307 @@
        "    }"
        "}"
        ))
+    (kotlin-file-lines "com/sfb/ssdreview/SsdVisionBridge.kt"
+      (
+       "package com.sfb.ssdreview"
+       ""
+       "import android.graphics.Bitmap"
+       "import org.json.JSONArray"
+       "import org.json.JSONObject"
+       ""
+       "object SsdVisionBridge {"
+       "    data class NativeDetection("
+       "        val cells: MutableList<SsdCell>,"
+       "        val groups: MutableList<SsdGroup>"
+       "    )"
+       ""
+       "    private var loadError: Throwable? = null"
+       "    private val loaded: Boolean = try {"
+       "        System.loadLibrary(\"jerboa_vision\")"
+       "        true"
+       "    } catch (error: Throwable) {"
+       "        loadError = error"
+       "        false"
+       "    }"
+       ""
+       "    fun isAvailable(): Boolean = loaded"
+       ""
+       "    fun loadErrorMessage(): String = loadError?.message ?: \"\""
+       ""
+       "    fun detect(bitmap: Bitmap): NativeDetection? {"
+       "        if (!loaded || bitmap.width <= 0 || bitmap.height <= 0) return null"
+       "        val rgba = bitmapToRgba(bitmap)"
+       "        val jsonText = try {"
+       "            nativeDetectRgbaJson(bitmap.width, bitmap.height, rgba)"
+       "        } catch (error: Throwable) {"
+       "            loadError = error"
+       "            return null"
+       "        }"
+       "        val root = try { JSONObject(jsonText) } catch (_: Exception) { return null }"
+       "        if (!root.optBoolean(\"ok\", true)) {"
+       "            loadError = IllegalStateException(root.optString(\"error\", \"native detector failed\"))"
+       "            return null"
+       "        }"
+       "        val cells = parseCells(root.optJSONArray(\"cells\"))"
+       "        if (cells.isEmpty()) return null"
+       "        val groups = parseGroups(root.optJSONArray(\"groups\"))"
+       "        return NativeDetection(cells, groups)"
+       "    }"
+       ""
+       "    private fun bitmapToRgba(bitmap: Bitmap): ByteArray {"
+       "        val width = bitmap.width"
+       "        val height = bitmap.height"
+       "        val pixels = IntArray(width * height)"
+       "        bitmap.getPixels(pixels, 0, width, 0, 0, width, height)"
+       "        val out = ByteArray(width * height * 4)"
+       "        for (index in pixels.indices) {"
+       "            val pixel = pixels[index]"
+       "            val offset = index * 4"
+       "            out[offset] = ((pixel shr 16) and 0xff).toByte()"
+       "            out[offset + 1] = ((pixel shr 8) and 0xff).toByte()"
+       "            out[offset + 2] = (pixel and 0xff).toByte()"
+       "            out[offset + 3] = ((pixel ushr 24) and 0xff).toByte()"
+       "        }"
+       "        return out"
+       "    }"
+       ""
+       "    private fun parseCells(raw: JSONArray?): MutableList<SsdCell> {"
+       "        val cells = mutableListOf<SsdCell>()"
+       "        if (raw == null) return cells"
+       "        for (index in 0 until raw.length()) {"
+       "            val json = raw.optJSONObject(index) ?: continue"
+       "            val id = json.optString(\"id\", \"c%04d\".format(index + 1))"
+       "            cells.add(SsdCell("
+       "                id = id,"
+       "                x = json.optDouble(\"x\", 0.0).toFloat(),"
+       "                y = json.optDouble(\"y\", 0.0).toFloat(),"
+       "                w = json.optDouble(\"w\", 0.0).toFloat(),"
+       "                h = json.optDouble(\"h\", 0.0).toFloat(),"
+       "                detector = json.optString(\"detector\", \"rust-color\")"
+       "            ))"
+       "        }"
+       "        return cells"
+       "    }"
+       ""
+       "    private fun parseGroups(raw: JSONArray?): MutableList<SsdGroup> {"
+       "        val groups = mutableListOf<SsdGroup>()"
+       "        if (raw == null) return groups"
+       "        for (index in 0 until raw.length()) {"
+       "            val json = raw.optJSONObject(index) ?: continue"
+       "            val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()"
+       "            val idsArray = json.optJSONArray(\"cell_ids\") ?: JSONArray()"
+       "            val ids = mutableListOf<String>()"
+       "            for (idIndex in 0 until idsArray.length()) {"
+       "                val id = idsArray.optString(idIndex)"
+       "                if (id.isNotBlank()) ids.add(id)"
+       "            }"
+       "            groups.add(SsdGroup("
+       "                id = json.optString(\"id\", \"g%04d\".format(index + 1)),"
+       "                label = json.optString(\"label\"),"
+       "                boxTypeId = json.optString(\"box_type_id\"),"
+       "                status = json.optString(\"status\", \"candidate\"),"
+       "                count = json.optInt(\"count\", ids.size),"
+       "                bbox = FloatArray(4) { bboxArray.optDouble(it, 0.0).toFloat() },"
+       "                cellIds = ids,"
+       "                notes = json.optString(\"notes\")"
+       "            ))"
+       "        }"
+       "        return groups"
+       "    }"
+       ""
+       "    private external fun nativeDetectRgbaJson(width: Int, height: Int, rgba: ByteArray): String"
+       "}"
+       ))
+    (kotlin-file-lines "com/sfb/ssdreview/SsdOcr.kt"
+      (
+       "package com.sfb.ssdreview"
+       ""
+       "import android.content.Context"
+       "import android.graphics.Bitmap"
+       "import com.google.android.gms.tasks.Tasks"
+       "import com.google.mlkit.vision.common.InputImage"
+       "import com.google.mlkit.vision.text.TextRecognition"
+       "import com.google.mlkit.vision.text.latin.TextRecognizerOptions"
+       "import kotlin.math.max"
+       "import kotlin.math.min"
+       ""
+       "object SsdOcr {"
+       "    private data class OcrPattern(val tokens: List<String>, val label: String, val confidence: Float)"
+       "    private data class OcrCandidate(val label: String, val boxTypeId: String, val confidence: Float, val reason: String)"
+       ""
+       "    private val patterns = listOf("
+       "        OcrPattern(listOf(\"PH 1\", \"PH1\", \"PHASER 1\", \"PHASER1\"), \"Phaser-1\", 0.94f),"
+       "        OcrPattern(listOf(\"PH 2\", \"PH2\", \"PHASER 2\", \"PHASER2\"), \"Phaser-2\", 0.93f),"
+       "        OcrPattern(listOf(\"PH 3\", \"PH3\", \"PHASER 3\", \"PHASER3\"), \"Phaser-3\", 0.93f),"
+       "        OcrPattern(listOf(\"PH 4\", \"PH4\", \"PHASER 4\", \"PHASER4\"), \"Phaser-4\", 0.93f),"
+       "        OcrPattern(listOf(\"DISR\", \"DIS\", \"DISRUPTOR\"), \"Disruptor\", 0.91f),"
+       "        OcrPattern(listOf(\"PHOT\", \"PHOTON\"), \"Photon Torpedo\", 0.91f),"
+       "        OcrPattern(listOf(\"DRONE\", \"DRN\"), \"Drone Rack\", 0.90f),"
+       "        OcrPattern(listOf(\"PLASMA\", \"PL A\", \"PL F\", \"PL G\", \"PL S\", \"PL R\"), \"Plasma Torpedo\", 0.88f),"
+       "        OcrPattern(listOf(\"HELLBORE\", \"HB\"), \"Hellbore\", 0.90f),"
+       "        OcrPattern(listOf(\"FUSION\", \"FUS\"), \"Fusion Beam\", 0.90f),"
+       "        OcrPattern(listOf(\"ESG\"), \"ESG\", 0.90f),"
+       "        OcrPattern(listOf(\"LEFT WARP\", \"L WARP\", \"LWARP\"), \"Left Warp Engine\", 0.89f),"
+       "        OcrPattern(listOf(\"RIGHT WARP\", \"R WARP\", \"RWARP\"), \"Right Warp Engine\", 0.89f),"
+       "        OcrPattern(listOf(\"CENTER WARP\", \"C WARP\", \"CWARP\"), \"Center Warp Engine\", 0.88f),"
+       "        OcrPattern(listOf(\"WARP\"), \"Warp Engine\", 0.82f),"
+       "        OcrPattern(listOf(\"IMPULSE\", \"IMP\"), \"Impulse Engine\", 0.88f),"
+       "        OcrPattern(listOf(\"APR\"), \"APR\", 0.88f),"
+       "        OcrPattern(listOf(\"BAT\", \"BATT\", \"BATTERY\", \"BATTERIES\"), \"Battery\", 0.88f),"
+       "        OcrPattern(listOf(\"LAB\", \"LABS\"), \"Lab\", 0.90f),"
+       "        OcrPattern(listOf(\"BRIDGE\", \"BRDG\"), \"Bridge\", 0.88f),"
+       "        OcrPattern(listOf(\"SCAN\", \"SCANNER\", \"SCANNERS\"), \"Scanner\", 0.84f),"
+       "        OcrPattern(listOf(\"SENS\", \"SENSOR\", \"SENSORS\"), \"Sensor\", 0.84f),"
+       "        OcrPattern(listOf(\"DAMAGE CONTROL\", \"DAM CON\", \"DAMCON\"), \"Damage Control\", 0.89f),"
+       "        OcrPattern(listOf(\"TRAN\", \"TRANS\", \"TRANSPORTER\", \"TRANSPORTERS\"), \"Transporter\", 0.88f),"
+       "        OcrPattern(listOf(\"TRACT\", \"TRACTOR\", \"TRACTORS\"), \"Tractor Beam\", 0.88f),"
+       "        OcrPattern(listOf(\"PROBE\", \"PROBES\"), \"Probe Launcher\", 0.87f),"
+       "        OcrPattern(listOf(\"CARGO\"), \"Cargo\", 0.87f),"
+       "        OcrPattern(listOf(\"SHUTTLE\", \"SHUTTLES\", \"SHUTTLEBAY\"), \"Shuttle\", 0.84f),"
+       "        OcrPattern(listOf(\"SECURITY\"), \"Security\", 0.85f),"
+       "        OcrPattern(listOf(\"REPAIR\"), \"Repair\", 0.85f),"
+       "        OcrPattern(listOf(\"PA PANEL\", \"PA PANELS\", \"POWER ABSORBER\", \"POWER ABSORBERS\"), \"PA Panel\", 0.91f),"
+       "        OcrPattern(listOf(\"DISP DEV\", \"DISP DEVICE\", \"DISPLACEMENT DEV\", \"DISPLACEMENT DEVICE\", \"DISDEV\"), \"Displacement Device\", 0.91f)"
+       "    )"
+       ""
+       "    fun recognizeWords(bitmap: Bitmap): List<OcrWord> {"
+       "        val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)"
+       "        return try {"
+       "            val result = Tasks.await(recognizer.process(InputImage.fromBitmap(bitmap, 0)))"
+       "            val words = mutableListOf<OcrWord>()"
+       "            result.textBlocks.forEach { block ->"
+       "                block.lines.forEach { line ->"
+       "                    line.elements.forEach { element ->"
+       "                        val box = element.boundingBox ?: line.boundingBox ?: block.boundingBox ?: return@forEach"
+       "                        val text = element.text.trim()"
+       "                        if (text.isNotBlank()) {"
+       "                            words.add(OcrWord("
+       "                                text = text,"
+       "                                conf = 80f,"
+       "                                bbox = floatArrayOf(box.left.toFloat(), box.top.toFloat(), box.right.toFloat(), box.bottom.toFloat())"
+       "                            ))"
+       "                        }"
+       "                    }"
+       "                }"
+       "            }"
+       "            words"
+       "        } catch (_: Exception) {"
+       "            emptyList()"
+       "        } finally {"
+       "            recognizer.close()"
+       "        }"
+       "    }"
+       ""
+       "    fun applyOcrGuesses(context: Context, session: SsdSession): Int {"
+       "        if (session.ocrWords.isEmpty()) return 0"
+       "        val race = raceKey(session.sourceName + \" \" + session.sourceUri)"
+       "        var applied = 0"
+       "        session.groups.forEach { group ->"
+       "            if (!shouldReplaceLabel(group)) return@forEach"
+       "            val text = nearbyText(session.ocrWords, group.bbox, session.imageWidth, session.imageHeight)"
+       "            val best = ocrCandidates(context, text, race).firstOrNull() ?: return@forEach"
+       "            group.label = \"${best.label}?\""
+       "            if (best.boxTypeId.isNotBlank()) group.boxTypeId = best.boxTypeId"
+       "            group.notes = appendNote(group.notes, \"OCR: ${best.label}, ${best.reason}\")"
+       "            applied += 1"
+       "        }"
+       "        return applied"
+       "    }"
+       ""
+       "    private fun nearbyText(words: List<OcrWord>, bbox: FloatArray, imageWidth: Int, imageHeight: Int): String {"
+       "        val x1 = bbox.getOrElse(0) { 0f }"
+       "        val y1 = bbox.getOrElse(1) { 0f }"
+       "        val x2 = bbox.getOrElse(2) { 0f }"
+       "        val y2 = bbox.getOrElse(3) { 0f }"
+       "        val padX = max(45f, (x2 - x1) * 1.8f)"
+       "        val padY = max(35f, (y2 - y1) * 1.2f)"
+       "        val rect = floatArrayOf("
+       "            max(0f, x1 - padX),"
+       "            max(0f, y1 - padY),"
+       "            min(imageWidth.toFloat(), x2 + padX),"
+       "            min(imageHeight.toFloat(), y2 + padY)"
+       "        )"
+       "        return words.filter { centerInside(it.bbox, rect) }"
+       "            .joinToString(\" \") { it.text }"
+       "            .take(240)"
+       "    }"
+       ""
+       "    private fun centerInside(bbox: FloatArray, rect: FloatArray): Boolean {"
+       "        if (bbox.size < 4 || rect.size < 4) return false"
+       "        val cx = (bbox[0] + bbox[2]) / 2f"
+       "        val cy = (bbox[1] + bbox[3]) / 2f"
+       "        return cx >= rect[0] && cx <= rect[2] && cy >= rect[1] && cy <= rect[3]"
+       "    }"
+       ""
+       "    private fun ocrCandidates(context: Context, text: String, race: String): List<OcrCandidate> {"
+       "        if (text.isBlank()) return emptyList()"
+       "        val seen = mutableSetOf<String>()"
+       "        val candidates = mutableListOf<OcrCandidate>()"
+       "        patterns.forEach { pattern ->"
+       "            if (!anyTokenMatch(text, pattern.tokens)) return@forEach"
+       "            if (suppressedByRace(race, pattern.label)) return@forEach"
+       "            val resolved = BoxTypes.resolve(context, pattern.label)"
+       "            val label = resolved?.name ?: pattern.label"
+       "            val key = compact(label)"
+       "            if (seen.add(key)) {"
+       "                candidates.add(OcrCandidate(label, resolved?.id ?: \"\", pattern.confidence, \"nearby text: ${text.take(80)}\"))"
+       "            }"
+       "        }"
+       "        return candidates.sortedByDescending { it.confidence }"
+       "    }"
+       ""
+       "    private fun anyTokenMatch(text: String, tokens: List<String>): Boolean {"
+       "        val normalized = normalizeOcr(text)"
+       "        val compactText = compact(text)"
+       "        return tokens.any { token ->"
+       "            val tokenNormal = normalizeOcr(token)"
+       "            val tokenCompact = compact(token)"
+       "            \" $normalized \".contains(\" $tokenNormal \") || compactText.contains(tokenCompact)"
+       "        }"
+       "    }"
+       ""
+       "    private fun shouldReplaceLabel(group: SsdGroup): Boolean {"
+       "        val text = group.label.trim()"
+       "        if (text.isBlank()) return true"
+       "        if (text.endsWith(\"?\")) return true"
+       "        if (Regex(\"\"\"^\\d+\\s+boxes$\"\"\").matches(text)) return true"
+       "        return group.status == \"candidate\" && group.notes.startsWith(\"Guess:\")"
+       "    }"
+       ""
+       "    private fun suppressedByRace(race: String, label: String): Boolean {"
+       "        val key = compact(label)"
+       "        if (race == \"andromedan\" && key.contains(\"shield\")) return true"
+       "        if (race != \"andromedan\" && (key.contains(\"papanel\") || key.contains(\"powerabsorber\") || key.contains(\"displacementdevice\"))) return true"
+       "        return false"
+       "    }"
+       ""
+       "    private fun appendNote(existing: String, note: String): String ="
+       "        if (existing.isBlank()) note else \"$existing; $note\""
+       ""
+       "    private fun normalizeOcr(value: String): String = value.uppercase()"
+       "        .replace(Regex(\"[^A-Z0-9]+\"), \" \")"
+       "        .trim()"
+       "        .replace(Regex(\"\\\\s+\"), \" \")"
+       ""
+       "    private fun compact(value: String): String = value.lowercase().filter { it.isLetterOrDigit() }"
+       ""
+       "    private fun raceKey(text: String): String {"
+       "        val lower = text.lowercase()"
+       "        return when {"
+       "            lower.contains(\"andromedan\") || lower.contains(\"module_c3\") || lower.contains(\"module c3\") -> \"andromedan\""
+       "            lower.contains(\"federation\") -> \"federation\""
+       "            lower.contains(\"klingon\") -> \"klingon\""
+       "            lower.contains(\"romulan\") -> \"romulan\""
+       "            lower.contains(\"gorn\") -> \"gorn\""
+       "            lower.contains(\"kzinti\") -> \"kzinti\""
+       "            lower.contains(\"lyran\") -> \"lyran\""
+       "            lower.contains(\"tholian\") -> \"tholian\""
+       "            lower.contains(\"hydran\") -> \"hydran\""
+       "            else -> \"\""
+       "        }"
+       "    }"
+       "}"
+       ))
     (kotlin-file-lines "com/sfb/ssdreview/SsdReviewView.kt"
       (
        "package com.sfb.ssdreview"
@@ -2209,13 +2696,20 @@
        "            field = value"
        "            fitImage()"
        "        }"
-       "    var session: SsdSession? = null"
-       "        set(value) {"
-       "            field = value"
-       "            invalidate()"
-       "        }"
-       "    var selectMode: Boolean = false"
-       "    var selectedGroupId: String? = null"
+	       "    var session: SsdSession? = null"
+	       "        set(value) {"
+	       "            field = value"
+	       "            ssdArea = value?.ssdArea"
+	       "            invalidate()"
+	       "        }"
+	       "    var selectMode: Boolean = false"
+	       "    var snapSelectionToCells: Boolean = true"
+	       "    var ssdArea: FloatArray? = null"
+	       "        set(value) {"
+	       "            field = value"
+	       "            invalidate()"
+	       "        }"
+	       "    var selectedGroupId: String? = null"
        "        set(value) {"
        "            field = value"
        "            invalidate()"
@@ -2233,40 +2727,41 @@
        "        style = Paint.Style.FILL"
        "        textAlign = Paint.Align.CENTER"
        "    }"
-       "    private val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {"
-       "        color = Color.argb(220, 255, 255, 255)"
-       "        style = Paint.Style.FILL"
-       "    }"
-       "    private var scale = 1f"
+	       "    private val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {"
+	       "        color = Color.argb(220, 255, 255, 255)"
+	       "        style = Paint.Style.FILL"
+	       "    }"
+	       "    private val density = context.resources.displayMetrics.density"
+	       "    private val selectionOffsetY = 70f * density"
+	       "    private var scale = 1f"
        "    private var offsetX = 0f"
        "    private var offsetY = 0f"
        "    private var rotationDegrees = 0"
        "    private var lastX = 0f"
        "    private var lastY = 0f"
-       "    private var dragStart: FloatArray? = null"