Skip to content

Revert "Revert "add Brush Designer ViewModel, preview, and app wiring""#70

Merged
romanofranz merged 2 commits into
mainfrom
revert-69-revert-59-brush-designer-pr-a
May 7, 2026
Merged

Revert "Revert "add Brush Designer ViewModel, preview, and app wiring""#70
romanofranz merged 2 commits into
mainfrom
revert-69-revert-59-brush-designer-pr-a

Conversation

@maxmmitchell
Copy link
Copy Markdown
Contributor

@maxmmitchell maxmmitchell commented May 7, 2026

See most recent commit for the fix to the build issues caused by #67. The rest of the code here was reviewed in #59.

Namely, naive experimental model being renamed to passthrough, and spring model being deleted. I also got rid of some strings we aren't going to need in #68 after that one is updated to the new proto as well (spring model, experimental naive model, and texture animation)

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the 'Brush Designer' feature, enabling users to create, test, and export custom ink brushes. The implementation includes a new ViewModel for Protobuf manipulation, adaptive UI components for the designer interface, and integration into the app's navigation and settings. Feedback highlights critical performance and stability improvements for the BrushDesignerViewModel, specifically recommending that texture decoding be moved to a background thread to avoid UI stutters and that defensive bounds checks be added to coat update methods to prevent potential crashes.

Comment on lines +503 to +518

fun setTextureStore(store: CahierTextureBitmapStore) {
this.textureStore = store
// Immediately populate the store from any textures already in the proto,
// since previewBrushFamily's decode callback has a 150ms debounce.
val bitmapMap = repository.activeBrushProto.value.textureIdToBitmapMap
bitmapMap.forEach { (id, byteString) ->
if (store.get(id) == null) {
try {
val bytes = byteString.toByteArray()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?.let { store.loadTexture(id, it) }
} catch (_: Exception) { /* logged during decode */ }
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The setTextureStore function performs image decoding and texture loading on the main thread. Since this is called from a LaunchedEffect in the UI (PreviewPane.kt), it will block the main thread and cause UI stutters or ANRs if the brush contains large or multiple textures. This logic should be moved to a background thread using viewModelScope.launch(Dispatchers.IO).

    fun setTextureStore(store: CahierTextureBitmapStore) {
        this.textureStore = store
        // Immediately populate the store from any textures already in the proto,
        // since previewBrushFamily's decode callback has a 150ms debounce.
        viewModelScope.launch(Dispatchers.IO) {
            val bitmapMap = repository.activeBrushProto.value.textureIdToBitmapMap
            bitmapMap.forEach { (id, byteString) ->
                if (store.get(id) == null) {
                    try {
                        val bytes = byteString.toByteArray()
                        BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                            ?.let { store.loadTexture(id, it) }
                    } catch (e: Exception) {
                        android.util.Log.e(TAG, "Failed to decode texture $id", e)
                    }
                }
            }
        }
    }

Comment on lines +251 to +262
fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) {
val familyBuilder = repository.activeBrushProto.value.toBuilder()
val index = _selectedCoatIndex.value

val coatBuilder = familyBuilder.getCoats(index).toBuilder()
val tipBuilder = coatBuilder.tip.toBuilder()
updateBlock(tipBuilder)

coatBuilder.setTip(tipBuilder)
familyBuilder.setCoats(index, coatBuilder)
repository.updateActiveBrushProto(familyBuilder.build())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The updateTip function lacks a bounds check for _selectedCoatIndex. If the index is invalid (e.g., due to a race condition or malformed proto load), familyBuilder.getCoats(index) will throw an IndexOutOfBoundsException. A defensive check should be added to ensure the index is within the current coatsCount.

Suggested change
fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) {
val familyBuilder = repository.activeBrushProto.value.toBuilder()
val index = _selectedCoatIndex.value
val coatBuilder = familyBuilder.getCoats(index).toBuilder()
val tipBuilder = coatBuilder.tip.toBuilder()
updateBlock(tipBuilder)
coatBuilder.setTip(tipBuilder)
familyBuilder.setCoats(index, coatBuilder)
repository.updateActiveBrushProto(familyBuilder.build())
}
fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) {
val familyBuilder = repository.activeBrushProto.value.toBuilder()
val index = _selectedCoatIndex.value
if (index < 0 || index >= familyBuilder.coatsCount) return
val coatBuilder = familyBuilder.getCoats(index).toBuilder()
val tipBuilder = coatBuilder.tip.toBuilder()
updateBlock(tipBuilder)
coatBuilder.setTip(tipBuilder)
familyBuilder.setCoats(index, coatBuilder)
repository.updateActiveBrushProto(familyBuilder.build())
}

Comment on lines +264 to +286
fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) {
val familyBuilder = repository.activeBrushProto.value.toBuilder()
val index = _selectedCoatIndex.value

if (familyBuilder.coatsCount <= index) {
familyBuilder.addCoats(
ProtoBrushCoat.newBuilder()
.setTip(ProtoBrushTip.newBuilder())
)
}
val coatBuilder = familyBuilder.getCoats(index).toBuilder()

if (coatBuilder.paintPreferencesCount == 0) {
coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder())
}
val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder()

paintBuilder.selfOverlap = overlap

coatBuilder.setPaintPreferences(0, paintBuilder)
familyBuilder.setCoats(index, coatBuilder)
repository.updateActiveBrushProto(familyBuilder.build())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The bounds check in updateSelfOverlap is flawed. If index is significantly greater than coatsCount, calling addCoats once (line 269) will not make index valid, leading to an IndexOutOfBoundsException at line 274. It is safer to verify the index is within bounds and return early if not, maintaining consistency with other update methods.

    fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) {
        val familyBuilder = repository.activeBrushProto.value.toBuilder()
        val index = _selectedCoatIndex.value

        if (index < 0 || index >= familyBuilder.coatsCount) return

        val coatBuilder = familyBuilder.getCoats(index).toBuilder()

        if (coatBuilder.paintPreferencesCount == 0) {
            coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder())
        }
        val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder()

        paintBuilder.selfOverlap = overlap

        coatBuilder.setPaintPreferences(0, paintBuilder)
        familyBuilder.setCoats(index, coatBuilder)
        repository.updateActiveBrushProto(familyBuilder.build())
    }

Also remove some strings which we won't need in PR B once that one is updated as well
@maxmmitchell maxmmitchell marked this pull request as ready for review May 7, 2026 12:41
@romanofranz romanofranz merged commit f06ba86 into main May 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants