From 0d693a8d313a06393dcc1e59ebbf84f37a3e808e Mon Sep 17 00:00:00 2001 From: Chris Assigbe Date: Thu, 9 Apr 2026 17:46:06 -0400 Subject: [PATCH 1/9] feat: add Brush Designer UI with tabbed editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the full Brush Designer screen accessible from Settings, including: UI Components: - BrushDesignerScreen with adaptive layout (compact/expanded) - Three editor tabs: Tip Shape, Paint, and Behaviors - NumericField with slider, ± buttons, and tap-to-edit dialog - EditableListWidget for managing behaviors, nodes, and texture layers - PreviewPane with live stroke rendering via DrawingSurface - TipPreview for visual tip shape feedback - MathCurvePreview for response curve visualization - NodeEditors for Source, Damping, Target, ResponseCurve, BinaryOp, etc. Features: - Proto editing via BrushDesignerViewModel - Import/Export .brush files (GZIP-compressed protobuf) - Save to Palette (Room DB integration) - Load from Brushes(both stock and custom brushes) or My Palette - Standard and Advanced Dynamics presets - Texture import with thumbnail preview - Color picker for brush paint --- .../java/com/example/cahier/features/home/SettingsScreen.kt | 1 + app/src/main/res/values/strings.xml | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt index e3c4510..421430d 100644 --- a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt +++ b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 701a044..29120fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,7 +85,6 @@ Brush Designer - Editing controls coming in the next update. Use the preview canvas to draw test strokes. Close Stock Brushes My Palette @@ -1020,4 +1019,4 @@ Window Size (ms) Upsampling Frequency (Hz) - \ No newline at end of file + From 35e5266c6ec6848442859742e9b70530df865508 Mon Sep 17 00:00:00 2001 From: Chris Assigbe Date: Thu, 9 Apr 2026 20:15:50 -0400 Subject: [PATCH 2/9] address Gemini Code Assist comments and dix test failures --- .../brushdesigner/viewmodel/BrushDesignerViewModelTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt index d5b8f12..58177c8 100644 --- a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt +++ b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt @@ -112,6 +112,8 @@ class BrushDesignerViewModelTest { viewModel.saveToPalette(brushName).join() + kotlinx.coroutines.delay(500) + val savedBrushes = customBrushDao.getAllCustomBrushes().first() assertTrue(savedBrushes.any { it.name == brushName }) } From ff1aebd6968870e2a49d3d48ea808beb28c82bb4 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:49:29 +0000 Subject: [PATCH 3/9] Add converters and repository for brushgraph --- .../brushgraph/data/BrushFamilyConverter.kt | 378 ++++++++ .../brushgraph/data/BrushGraphConverter.kt | 430 +++++++++ .../brushgraph/data/BrushGraphRepository.kt | 654 +++++++++++++ .../data/BrushFamilyConverterTest.kt | 905 ++++++++++++++++++ .../data/BrushGraphConverterTest.kt | 227 +++++ 5 files changed, 2594 insertions(+) create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt create mode 100644 app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt create mode 100644 app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt new file mode 100644 index 0000000..e39ddd2 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt @@ -0,0 +1,378 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.data + +import androidx.ink.brush.BrushFamily +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.toBrushFamily +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +/** Utility to convert a [BrushGraph] data model into a functional [BrushFamily] object. */ +object BrushFamilyConverter { + + /** + * Converts a [BrushGraph] into a [BrushFamily]. + * + * @throws IllegalStateException if the graph is invalid. + */ + fun convert(graph: BrushGraph): BrushFamily { + return convertIntoProto(graph).toBrushFamily() + } + + /** Converts a [BrushGraph] into a [ProtoBrushFamily]. */ + fun convertIntoProto(graph: BrushGraph): ProtoBrushFamily { + val issues = GraphValidator.validateAll(graph) + val criticalErrors = issues.filter { it.severity == ValidationSeverity.ERROR } + if (criticalErrors.isNotEmpty()) { + throw criticalErrors.first() + } + + val familyNode = graph.nodes.first { it.data is NodeData.Family } + val familyData = familyNode.data as NodeData.Family + + val coatEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == familyNode.id } + val sortedCoatEdges = familyData.coatPortIds.mapNotNull { portId -> + coatEdges.find { it.toPortId == portId } + } + if (sortedCoatEdges.isEmpty()) { + throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_no_coat), + nodeId = familyNode.id, + ) + } + + val behaviorCache = mutableMapOf>>() + val coats = sortedCoatEdges.mapNotNull { edge -> + val coatNode = + graph.nodes.find { it.id == edge.fromNodeId } + ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_node_not_found, listOf(edge.fromNodeId))) + if (coatNode.isDisabled) null + else createCoat(coatNode, graph, behaviorCache) + } + + return ProtoBrushFamily.newBuilder() + .addAllCoats(coats) + .setInputModel(familyData.inputModel) + .setClientBrushFamilyId(familyData.clientBrushFamilyId) + .setDeveloperComment(familyData.developerComment) + .build() + } + + fun createCoat( + coatNode: GraphNode, + graph: BrushGraph, + behaviorCache: MutableMap>>, + ): ProtoBrushCoat { + val inputs = graph.edges.filter { !it.isDisabled && it.toNodeId == coatNode.id } + val coatData = coatNode.data as NodeData.Coat + + val tipEdge = + inputs.find { it.toPortId == coatData.tipPortId } + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_tip_input, listOf(coatNode.id)), + nodeId = coatNode.id, + ) + + val paintEdges = coatData.paintPortIds.mapNotNull { portId -> + inputs.find { it.toPortId == portId } + } + if (paintEdges.isEmpty()) { + throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_paint_input, listOf(coatNode.id)), + nodeId = coatNode.id, + ) + } + + val tip = createTip(tipEdge.fromNodeId, graph, behaviorCache, mutableSetOf()) + + val builder = ProtoBrushCoat.newBuilder() + .setTip(tip) + + for (edge in paintEdges) { + val paint = createPaint(edge.fromNodeId, graph) + builder.addPaintPreferences(paint) + } + + return builder.build() + } + + private fun createTip( + nodeId: String, + graph: BrushGraph, + behaviorCache: MutableMap>>, + path: MutableSet, + ): ProtoBrushTip { + val graphNode = + graph.nodes.find { it.id == nodeId } + ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) + val data = + graphNode.data as? NodeData.Tip + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_expected_node_type, listOf("Tip", graphNode.data::class.simpleName ?: "Unknown")), + nodeId = nodeId, + ) + + val builder = data.tip.toBuilder() + builder.clearBehaviors() + + val behaviorEdges = data.behaviorPortIds.mapNotNull { portId -> + graph.edges.find { !it.isDisabled && it.toNodeId == nodeId && it.toPortId == portId } + } + for (edge in behaviorEdges) { + val actualSources = GraphValidator.findActualSourceNode(graph, edge.fromNodeId) + for (actualSourceNode in actualSources) { + val behaviorLists = collectBehaviorNodes(actualSourceNode.id, graph, behaviorCache, path) + for (nodeList in behaviorLists) { + val comment = (actualSourceNode.data as? NodeData.Behavior)?.developerComment ?: "" + builder.addBehaviors( + ProtoBrushBehavior.newBuilder() + .addAllNodes(nodeList) + .setDeveloperComment(comment) + .build() + ) + } + } + } + + return builder.build() + } + + private fun collectBehaviorNodes( + nodeId: String, + graph: BrushGraph, + cache: MutableMap>>, + path: MutableSet, + ): List> { + if (path.contains(nodeId)) { + throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_cycle_detected, listOf(nodeId)), nodeId = nodeId) + } + cache[nodeId]?.let { return it } + + val graphNode = graph.nodes.find { it.id == nodeId } ?: return emptyList() + val data = graphNode.data as? NodeData.Behavior ?: return emptyList() + val inputEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + + path.add(nodeId) + val resultLists = mutableListOf>() + + fun createDefaultNode(): ProtoBrushBehavior.Node { + return ProtoBrushBehavior.Node.newBuilder() + .setSourceNode(ProtoBrushBehavior.SourceNode.newBuilder().setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE)) + .build() + } + + val labels = data.inputLabels() + val nodeCase = data.node.nodeCase + + val ids = if (data.inputPortIds.isEmpty()) { + when (nodeCase) { + ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf("input_0", "input_1") + ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf("angle_0", "mag_0") + ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("Value", "Start", "End") + else -> if (labels.size == 1) listOf("Input") else emptyList() + } + } else data.inputPortIds + + val sortedEdges = ids.map { portId -> + inputEdges.find { it.toPortId == portId } + } + + if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE) { + val setLists = mutableListOf>>() + + for (edge in sortedEdges) { + val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + val lists = mutableListOf>() + if (sources.isEmpty()) { + lists.add(listOf(createDefaultNode())) + } else { + for (source in sources) { + lists.addAll(collectBehaviorNodes(source.id, graph, cache, path)) + } + } + setLists.add(lists) + } + + if (setLists.size >= 2) { + var currentCombinedLists = setLists[0] + + for (i in 1 until setLists.size) { + val nextLists = setLists[i] + val numInstances = maxOf(currentCombinedLists.size, nextLists.size) + val newCombinedLists = mutableListOf>() + + for (j in 0 until numInstances) { + val list1 = currentCombinedLists.getOrNull(j) ?: currentCombinedLists.last() + val list2 = nextLists.getOrNull(j) ?: nextLists.last() + + val combinedList = mutableListOf() + combinedList.addAll(list1) + combinedList.addAll(list2) + combinedList.add(data.node) + + newCombinedLists.add(combinedList) + } + currentCombinedLists = newCombinedLists + } + resultLists.addAll(currentCombinedLists) + } else { + // Fallback if less than 2 inputs + resultLists.add(listOf(data.node)) + } + } else if (labels.size > 1) { + // Multi-input behavior node (e.g. PolarTarget) + val chunkedEdges = sortedEdges.chunked(labels.size) + + for (set in chunkedEdges) { + val setLists = mutableListOf>>() + for (edge in set) { + val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + val lists = mutableListOf>() + if (sources.isEmpty()) { + lists.add(listOf(createDefaultNode())) + } else { + for (src in sources) { + lists.addAll(collectBehaviorNodes(src.id, graph, cache, path)) + } + } + setLists.add(lists) + } + + // Parallel mapping (zip) across all inputs in the set + val numInstances = setLists.map { it.size }.maxOrNull() ?: 0 + for (j in 0 until numInstances) { + val combinedList = mutableListOf() + for (lists in setLists) { + val list = lists.getOrNull(j) ?: lists.last() + combinedList.addAll(list) + } + combinedList.add(data.node) // Add Op node at the end (post-order) + resultLists.add(combinedList) + } + } + } else { + // Single input node or Source node + if (sortedEdges.isEmpty()) { + // Source node + resultLists.add(listOf(data.node)) + } else { + for (edge in sortedEdges) { + val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + if (sources.isNotEmpty()) { + for (source in sources) { + val childLists = collectBehaviorNodes(source.id, graph, cache, path) + for (childList in childLists) { + val newList = mutableListOf() + newList.addAll(childList) + newList.add(data.node) // Add current node at the end + resultLists.add(newList) + } + } + } else { + // Pass-through or invalid source + resultLists.add(listOf(data.node)) + } + } + } + } + + path.remove(nodeId) + cache[nodeId] = resultLists + return resultLists + } + + private fun createPaint(nodeId: String, graph: BrushGraph): ProtoBrushPaint { + val graphNode = + graph.nodes.find { it.id == nodeId } + ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) + val data = + graphNode.data as? NodeData.Paint + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_expected_node_type, listOf("Paint", graphNode.data::class.simpleName ?: "Unknown")), + nodeId = nodeId, + ) + + val textureEdges = data.texturePortIds.mapNotNull { portId -> + graph.edges.find { edge -> + if (edge.isDisabled || edge.toNodeId != nodeId || edge.toPortId != portId) return@find false + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.TextureLayer + } + } + + val colorEdges = data.colorPortIds.mapNotNull { portId -> + graph.edges.find { edge -> + if (edge.isDisabled || edge.toNodeId != nodeId || edge.toPortId != portId) return@find false + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.ColorFunction + } + } + + val builder = data.paint.toBuilder() + builder.clearTextureLayers() + builder.clearColorFunctions() + + for (edge in textureEdges) { + builder.addTextureLayers(createTextureLayer(edge.fromNodeId, graph)) + } + for (edge in colorEdges) { + builder.addColorFunctions(createColorFunction(edge.fromNodeId, graph)) + } + + return builder.build() + } + + private fun createTextureLayer(nodeId: String, graph: BrushGraph): ProtoBrushPaint.TextureLayer { + val graphNode = + graph.nodes.find { it.id == nodeId } + ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) + val data = + graphNode.data as? NodeData.TextureLayer + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_expected_node_type, listOf("TextureLayer", graphNode.data::class.simpleName ?: "Unknown")), + nodeId = nodeId, + ) + return data.layer + } + + private fun createColorFunction(nodeId: String, graph: BrushGraph): ProtoColorFunction { + val graphNode = + graph.nodes.find { it.id == nodeId } + ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) + val data = + graphNode.data as? NodeData.ColorFunction + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_expected_node_type, listOf("ColorFunction", graphNode.data::class.simpleName ?: "Unknown")), + nodeId = nodeId, + ) + return data.function + } + +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt new file mode 100644 index 0000000..5a83c9b --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt @@ -0,0 +1,430 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.data + +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.encode +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.GZIPInputStream +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +/** Utility to convert a functional [BrushFamily] into a [BrushGraph] data model. */ +object BrushGraphConverter { + + /** Converts a [BrushFamily] into a [BrushGraph]. */ + fun fromBrushFamily(family: BrushFamily): BrushGraph { + val baos = ByteArrayOutputStream() + family.encode(baos) + val compressedBytes = baos.toByteArray() + val bais = ByteArrayInputStream(compressedBytes) + val proto = GZIPInputStream(bais).use { ProtoBrushFamily.parseFrom(it) } + return fromProtoBrushFamily(proto) + } + + /** Converts a [ProtoBrushFamily] into a [BrushGraph]. */ + fun fromProtoBrushFamily(family: ProtoBrushFamily): BrushGraph { + val nodes = mutableListOf() + val edges = mutableListOf() + + val familyNodeId = UUID.randomUUID().toString() + val coatPortIds = (0 until family.coatsCount).map { UUID.randomUUID().toString() } + val familyData = + NodeData.Family( + clientBrushFamilyId = family.clientBrushFamilyId, + developerComment = family.developerComment, + inputModel = family.inputModel, + coatPortIds = coatPortIds, + ) + nodes.add(GraphNode(id = familyNodeId, data = familyData)) + + val behaviorDeduplicationMap = mutableMapOf>, InternalNodeInfo>() + val assignedNodeIds = mutableSetOf() + val textureDeduplicationMap = mutableMapOf() + val colorDeduplicationMap = mutableMapOf() + + for (index in 0 until family.coatsCount) { + val coat = family.getCoats(index) + val coatId = UUID.randomUUID().toString() + val paintPortIds = (0 until coat.paintPreferencesCount).map { UUID.randomUUID().toString() } + val coatData = NodeData.Coat(paintPortIds = paintPortIds) + val coatNode = GraphNode(id = coatId, data = coatData) + nodes.add(coatNode) + edges.add( + GraphEdge( + fromNodeId = coatId, + toNodeId = familyNodeId, + toPortId = coatPortIds[index] + ) + ) + + val (tipId, tipOutputPortId) = convertTip(coat.tip, nodes, edges, behaviorDeduplicationMap, assignedNodeIds) + edges.add( + GraphEdge( + fromNodeId = tipId, + toNodeId = coatId, + toPortId = coatData.tipPortId + ) + ) + + var paintIndex = 0 + for (paint in coat.paintPreferencesList) { + val paintData = NodeData.Paint(paint) + val (paintId, paintOutputPortId) = convertPaint(paint, nodes, edges, textureDeduplicationMap, colorDeduplicationMap) + edges.add( + GraphEdge( + fromNodeId = paintId, + toNodeId = coatId, + toPortId = paintPortIds[paintIndex++] + ) + ) + } + } + + val initialGraph = BrushGraph(nodes = nodes, edges = edges) + return deduplicateDownstream(initialGraph) + } + + private fun convertTip( + tip: ProtoBrushTip, + nodes: MutableList, + edges: MutableList, + deduplicationMap: MutableMap>, InternalNodeInfo>, + assignedNodeIds: MutableSet, + ): Pair { + val tipId = UUID.randomUUID().toString() + val usedPortIds = mutableListOf() + + for (behavior in tip.behaviorsList) { + val terminalNodes = convertBehaviorGraph(behavior, nodes, edges, deduplicationMap, assignedNodeIds) + for ((terminalId, _) in terminalNodes) { + val alreadyConnected = edges.any { it.toNodeId == tipId && it.fromNodeId == terminalId } + if (!alreadyConnected) { + val portId = UUID.randomUUID().toString() + edges.add( + GraphEdge( + fromNodeId = terminalId, + toNodeId = tipId, + toPortId = portId + ) + ) + usedPortIds.add(portId) + } + } + } + + val tipData = NodeData.Tip(tip, behaviorPortIds = usedPortIds) + nodes.add(GraphNode(id = tipId, data = tipData)) + + return Pair(tipId, "output") + } + + private fun convertPaint( + paint: ProtoBrushPaint, + nodes: MutableList, + edges: MutableList, + textureDeduplicationMap: MutableMap, + colorDeduplicationMap: MutableMap, + ): Pair { + val paintId = UUID.randomUUID().toString() + val texturePortIds = (0 until paint.textureLayersCount).map { UUID.randomUUID().toString() } + val colorPortIds = (0 until paint.colorFunctionsCount).map { UUID.randomUUID().toString() } + val paintData = NodeData.Paint(paint, texturePortIds = texturePortIds, colorPortIds = colorPortIds) + nodes.add(GraphNode(id = paintId, data = paintData)) + + val tempTexturePortIds = texturePortIds + val tempColorPortIds = colorPortIds + val usedTexturePortIds = mutableListOf() + val usedColorPortIds = mutableListOf() + + var layerIndex = 0 + for (layer in paint.textureLayersList) { + val isNew = !textureDeduplicationMap.containsKey(layer) + val layerId = textureDeduplicationMap.getOrPut(layer) { UUID.randomUUID().toString() } + val layerData = NodeData.TextureLayer(layer) + + val alreadyConnected = edges.any { it.toNodeId == paintId && it.fromNodeId == layerId } + if (!alreadyConnected) { + val portId = tempTexturePortIds[layerIndex] + edges.add( + GraphEdge( + fromNodeId = layerId, + toNodeId = paintId, + toPortId = portId + ) + ) + usedTexturePortIds.add(portId) + } + + if (isNew) { + nodes.add( + GraphNode( + id = layerId, + data = layerData + ) + ) + } + layerIndex++ + } + + var colorIndex = 0 + for (cf in paint.colorFunctionsList) { + val isNew = !colorDeduplicationMap.containsKey(cf) + val cfId = colorDeduplicationMap.getOrPut(cf) { UUID.randomUUID().toString() } + val cfData = NodeData.ColorFunction(cf) + + val alreadyConnected = edges.any { it.toNodeId == paintId && it.fromNodeId == cfId } + if (!alreadyConnected) { + val portId = tempColorPortIds[colorIndex] + edges.add( + GraphEdge( + fromNodeId = cfId, + toNodeId = paintId, + toPortId = portId + ) + ) + usedColorPortIds.add(portId) + } + + if (isNew) { + nodes.add( + GraphNode( + id = cfId, + data = cfData + ) + ) + } + colorIndex++ + } + + val finalPaintData = NodeData.Paint(paint, texturePortIds = usedTexturePortIds, colorPortIds = usedColorPortIds) + nodes.removeIf { it.id == paintId } + nodes.add(GraphNode(id = paintId, data = finalPaintData)) + + return Pair(paintId, "output") + } + + private fun convertBehaviorGraph( + behavior: ProtoBrushBehavior, + nodes: MutableList, + edges: MutableList, + deduplicationMap: MutableMap>, InternalNodeInfo>, + assignedNodeIds: MutableSet, + ): List> { + val behaviorId = UUID.randomUUID().toString() + val nodeStack = mutableListOf() + val behaviorNodes = mutableListOf() + + for (protoNode in behavior.nodesList) { + val tempNodeData = NodeData.Behavior( + node = protoNode, + developerComment = behavior.developerComment, + behaviorId = behaviorId + ) + val inputCount = tempNodeData.inputLabels().size + + val children = mutableListOf() + for (i in 0 until inputCount) { + if (nodeStack.isNotEmpty()) { + children.add(0, nodeStack.removeAt(nodeStack.size - 1)) + } + } + + val childrenIds = children.map { it.id } + val key = Pair(protoNode, childrenIds) + + val existingInfo = deduplicationMap[key] + if (existingInfo != null) { + behaviorNodes.add(existingInfo) + if (protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + nodeStack.add(existingInfo) + } + continue + } + + val nodeId = UUID.randomUUID().toString() + val inputPortIds = (0 until children.size).map { UUID.randomUUID().toString() } + val nodeData = NodeData.Behavior( + node = protoNode, + developerComment = behavior.developerComment, + behaviorId = behaviorId, + inputPortIds = inputPortIds + ) + + val info = InternalNodeInfo(nodeId, nodeData, children) + behaviorNodes.add(info) + + deduplicationMap[key] = info + + if (protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + nodeStack.add(info) + } + } + + val childIds = behaviorNodes.flatMap { it.children.map { child -> child.id } }.toSet() + val terminalNodeInfos = behaviorNodes.filter { it.id !in childIds } + + fun buildGraphNode(info: InternalNodeInfo, depth: Int) { + if (assignedNodeIds.contains(info.id)) { + return + } + + nodes.add(GraphNode(id = info.id, data = info.data)) + + info.children.forEachIndexed { index, child -> + edges.add( + GraphEdge( + fromNodeId = child.id, + toNodeId = info.id, + toPortId = info.data.inputPortIds[index] + ) + ) + } + assignedNodeIds.add(info.id) + + info.children.forEach { buildGraphNode(it, depth + 1) } + } + + for (root in terminalNodeInfos) { + buildGraphNode(root, 0) + } + + return terminalNodeInfos.map { it.id to "output" } + } + + /** + * Performs a top-down deduplication pass on behavior nodes. + * + * NOTE: This method assumes that the `nodes` list is ordered bottom-up (sources first, + * then operators, then targets) as a result of the post-order traversal during construction. + * By processing the reversed list, we achieve top-down processing in a single pass. + * If the graph construction order changes in the future, this may need to be updated + * to perform a full topological sort first. + */ + private fun deduplicateDownstream(graph: BrushGraph): BrushGraph { + val nodes = graph.nodes.toMutableList() + val edges = graph.edges.toMutableList() + + // Filter and reverse behavior nodes to process top-down + val behaviorNodes = nodes.filter { it.data is NodeData.Behavior }.reversed() + + val removedNodeIds = mutableSetOf() + + for (node in behaviorNodes) { + if (removedNodeIds.contains(node.id)) continue + + val nodeData = node.data as NodeData.Behavior + val isInterpolation = nodeData.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE + val isBinaryOp = nodeData.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE + if (isInterpolation || isBinaryOp) continue + + val nodeOutputSet = edges.filter { it.fromNodeId == node.id && !it.isDisabled } + .map { it.toNodeId } + .sorted() + + if (nodeOutputSet.isEmpty()) continue + + // Find another node to merge with + val otherNode = nodes.find { other -> + if (other.id == node.id || removedNodeIds.contains(other.id)) return@find false + val otherData = other.data as? NodeData.Behavior ?: return@find false + if (otherData.node != nodeData.node) return@find false + + val otherOutputSet = edges.filter { it.fromNodeId == other.id && !it.isDisabled } + .map { it.toNodeId } + .sorted() + otherOutputSet == nodeOutputSet + } + + if (otherNode != null) { + val keptNode = otherNode + val nodeToRemove = node + + val keptData = keptNode.data as NodeData.Behavior + val dataToRemove = nodeToRemove.data as NodeData.Behavior + val newData = keptData.copy(inputPortIds = keptData.inputPortIds + dataToRemove.inputPortIds) + + nodes.remove(keptNode) + val updatedKeptNode = keptNode.copy(data = newData) + nodes.add(updatedKeptNode) + + // Redirect incoming edges + val incomingEdges = edges.filter { it.toNodeId == nodeToRemove.id } + for (edge in incomingEdges) { + edges.remove(edge) + edges.add(edge.copy(toNodeId = keptNode.id)) + } + + // Remove outgoing edges of removed node and cleanup ports! + val outgoingEdges = edges.filter { it.fromNodeId == nodeToRemove.id } + for (edge in outgoingEdges) { + val parentNode = nodes.find { it.id == edge.toNodeId } + if (parentNode != null) { + val updatedParent = removePortFromNode(parentNode, edge.toPortId) + nodes.remove(parentNode) + nodes.add(updatedParent) + } + } + edges.removeAll(outgoingEdges) + + // Remove node from list + nodes.remove(nodeToRemove) + removedNodeIds.add(nodeToRemove.id) + } + } + return BrushGraph(nodes = nodes, edges = edges) + } + + private fun removePortFromNode(node: GraphNode, portId: String): GraphNode { + val data = node.data + val newData = when (data) { + is NodeData.Behavior -> { + data.copy(inputPortIds = data.inputPortIds.filter { it != portId }) + } + is NodeData.Tip -> { + data.copy(behaviorPortIds = data.behaviorPortIds.filter { it != portId }) + } + is NodeData.Paint -> { + data.copy( + texturePortIds = data.texturePortIds.filter { it != portId }, + colorPortIds = data.colorPortIds.filter { it != portId } + ) + } + else -> data + } + return node.copy(data = newData) + } + + private data class InternalNodeInfo( + val id: String, + val data: NodeData.Behavior, + val children: List + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt new file mode 100644 index 0000000..a7c21f4 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -0,0 +1,654 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.data + +import android.graphics.Bitmap +import android.util.Log +import com.example.cahier.R +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.OptIn +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Singleton +@OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) +class BrushGraphRepository @Inject constructor( + private val customBrushDao: CustomBrushDao, + val textureStore: CahierTextureBitmapStore +) { + private val _graph = MutableStateFlow(createDefaultGraph()) + val graph: StateFlow = _graph.asStateFlow() + + private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO) + init { + scope.launch { + graph + .drop(1) + .debounce(1000) + .collect { graph -> + try { + val family = BrushFamilyConverter.convert(graph) + val baos = ByteArrayOutputStream() + AndroidBrushFamilySerialization.encode(family, baos, textureStore) + customBrushDao.saveCustomBrush(com.example.cahier.developer.brushdesigner.data.CustomBrushEntity("__autosave__", baos.toByteArray())) + } catch (e: Exception) { + android.util.Log.e("BrushGraphRepository", "Failed to auto-save brush", e) + } + } + } + } + + private val _graphIssues = MutableStateFlow>(emptyList()) + val graphIssues: StateFlow> = _graphIssues.asStateFlow() + + fun setGraph(newGraph: BrushGraph) { + _graph.update { newGraph } + } + + fun clearGraph() { + _graph.update { createDefaultGraph() } + validate() + postDebug(DisplayText.Resource(R.string.bg_msg_graph_cleared)) + } + + fun postDebug(displayText: DisplayText) { + val newIssue = GraphValidationException(displayMessage = displayText, severity = ValidationSeverity.DEBUG) + _graphIssues.update { (it + newIssue).distinctBy { issue -> Triple(issue.displayMessage, issue.nodeId, issue.severity) } } + } + + fun validate(): Boolean { + val issues = GraphValidator.validateAll(_graph.value).toMutableList() + + val errorNodeIds = + issues.filter { it.severity == ValidationSeverity.ERROR }.mapNotNull { it.nodeId }.toSet() + val warningNodeIds = + issues.filter { it.severity == ValidationSeverity.WARNING }.mapNotNull { it.nodeId }.toSet() + + _graph.update { currentGraph -> + currentGraph.copy( + nodes = currentGraph.nodes.map { + it.copy( + hasError = errorNodeIds.contains(it.id), + hasWarning = warningNodeIds.contains(it.id) && !errorNodeIds.contains(it.id), + ) + } + ) + } + + _graphIssues.update { issues } + return issues.none { it.severity == ValidationSeverity.ERROR } + } + + fun clearIssues() { + _graphIssues.value = emptyList() + } + + suspend fun loadAutoSaveBrush(): Boolean { + val entity = customBrushDao.getAutoSaveBrush().firstOrNull() ?: return false + val decodedBytes = entity.brushBytes + return try { + val bais = ByteArrayInputStream(decodedBytes) + val family = AndroidBrushFamilySerialization.decode( + bais, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + } + id + } + ) + loadBrushFamily(family) + true + } catch (e: Exception) { + android.util.Log.e("BrushGraphRepository", "Failed to decode auto saved brush family", e) + false + } + } + + fun getBrushFamily(): androidx.ink.brush.BrushFamily? { + if (!validate()) return null + return try { + BrushFamilyConverter.convert(_graph.value) + } catch (e: Exception) { + val internalError = GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_internal_conversion, listOf(e.message ?: e.javaClass.simpleName))) + _graphIssues.update { currentIssues -> + (currentIssues + internalError).distinctBy { issue -> Triple(issue.displayMessage, issue.nodeId, issue.severity) } + } + null + } + } + + fun addNode(data: NodeData): String { + val newNode = GraphNode(id = UUID.randomUUID().toString(), data = data) + _graph.update { it.copy(nodes = it.nodes + newNode) } + validate() + return newNode.id + } + + fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { + var toPortId = initialToPortId + if (fromNodeId == toNodeId) return + + _graph.update { currentGraph -> + val nodesById = currentGraph.nodes.associateBy { it.id } + if (nodesById[fromNodeId] == null) return@update currentGraph + val toNode = nodesById[toNodeId] ?: return@update currentGraph + val existingEdge = currentGraph.edges.find { it.toNodeId == toNodeId && it.toPortId == toPortId } + if (existingEdge != null) { + if (existingEdge.fromNodeId != fromNodeId) return@update currentGraph + if (!existingEdge.isDisabled) return@update currentGraph + } + var newGraph = currentGraph + val toData = toNode.data + val toPort = toNode.getVisiblePorts(currentGraph).find { it.id == toPortId } + when (toPort) { + is Port.AddTexture -> { + val newPortId = UUID.randomUUID().toString() + val newData = (toData as NodeData.Paint).copy(texturePortIds = toData.texturePortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + is Port.AddColor -> { + val newPortId = UUID.randomUUID().toString() + val newData = (toData as NodeData.Paint).copy(colorPortIds = toData.colorPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + is Port.AddPaint -> { + val newPortId = UUID.randomUUID().toString() + val newData = (toData as NodeData.Coat).copy(paintPortIds = toData.paintPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + is Port.AddCoat -> { + val newPortId = UUID.randomUUID().toString() + val newData = (toData as NodeData.Family).copy(coatPortIds = toData.coatPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + is Port.AddBehavior -> { + val newPortId = UUID.randomUUID().toString() + val newData = (toData as NodeData.Tip).copy(behaviorPortIds = toData.behaviorPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + is Port.AddInput -> { + val data = toData as NodeData.Behavior + if (data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val newPortId1 = UUID.randomUUID().toString() + val newPortId2 = UUID.randomUUID().toString() + val newData = data.copy(inputPortIds = data.inputPortIds + listOf(newPortId1, newPortId2)) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId1 + } else { + val newPortId = UUID.randomUUID().toString() + val newData = data.copy(inputPortIds = data.inputPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { if (it.id == toNodeId) it.copy(data = newData) else it }) + toPortId = newPortId + } + } + else -> {} + } + val fromNode = nodesById[fromNodeId]!! + val fromPortId = if (fromNode.data.hasOutput()) "output" else return@update currentGraph + val newEdge = GraphEdge(fromNodeId = fromNodeId, toNodeId = toNodeId, toPortId = toPortId) + newGraph.copy(edges = newGraph.edges + newEdge) + } + validate() + } + + fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge { + val updatedEdge = edge.copy(isDisabled = isDisabled) + _graph.update { currentGraph -> + currentGraph.copy( + edges = currentGraph.edges.map { + if (it.fromNodeId == edge.fromNodeId && + it.toNodeId == edge.toNodeId && it.toPortId == edge.toPortId) updatedEdge else it + } + ) + } + validate() + return updatedEdge + } + + fun deleteEdge(edge: GraphEdge): Set { + var modifiedNodeIds = emptySet() + _graph.update { currentGraph -> + val (newGraph, ids) = calculateDeleteEdge(currentGraph, edge) + modifiedNodeIds = ids + newGraph + } + validate() + return modifiedNodeIds + } + + private fun calculateDeleteEdge( + currentGraph: BrushGraph, + edge: GraphEdge + ): Pair> { + val modifiedNodeIds = mutableSetOf() + val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } + val toData = toNode?.data + + if (toData != null) { + val filteredEdges = currentGraph.edges.filter { + !(it.fromNodeId == edge.fromNodeId && + it.toNodeId == edge.toNodeId && it.toPortId == edge.toPortId) + } + val remainingEdges = filteredEdges.filter { it.toNodeId == edge.toNodeId } + + var newGraph = currentGraph.copy(edges = filteredEdges) + + when (toData) { + is NodeData.Coat -> { + if (toData.paintPortIds.contains(edge.toPortId)) { + val newData = toData.copy(paintPortIds = toData.paintPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + is NodeData.Behavior -> { + val nodeCase = toData.node.nodeCase + if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val chunkedIds = toData.inputPortIds.chunked(2) + val pair = chunkedIds.find { it.contains(edge.toPortId) } + if (pair != null && pair.size == 2) { + val hasAngle = remainingEdges.any { it.toPortId == pair[0] } + val hasMag = remainingEdges.any { it.toPortId == pair[1] } + if (!hasAngle && !hasMag) { + val newData = toData.copy(inputPortIds = toData.inputPortIds - pair.toSet()) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + } else if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE) { + // Do nothing to inputPortIds for fixed schema nodes! + } else { + if (toData.inputPortIds.contains(edge.toPortId)) { + val newData = toData.copy(inputPortIds = toData.inputPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + } + is NodeData.Tip -> { + if (toData.behaviorPortIds.contains(edge.toPortId)) { + val newData = toData.copy(behaviorPortIds = toData.behaviorPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + is NodeData.Family -> { + if (toData.coatPortIds.contains(edge.toPortId)) { + val newData = toData.copy(coatPortIds = toData.coatPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + is NodeData.Paint -> { + if (toData.texturePortIds.contains(edge.toPortId)) { + val newData = toData.copy(texturePortIds = toData.texturePortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } else if (toData.colorPortIds.contains(edge.toPortId)) { + val newData = toData.copy(colorPortIds = toData.colorPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + else -> {} + } + return Pair(newGraph, modifiedNodeIds) + } + return Pair(currentGraph, emptySet()) + } + + fun deleteSelectedNodes(selectedNodeIds: Set): Set { + val modifiedNodeIds = mutableSetOf() + _graph.update { currentGraph -> + var g = currentGraph + val edgesLeavingSelectedSet = g.edges.filter { edge -> + selectedNodeIds.contains(edge.fromNodeId) && !selectedNodeIds.contains(edge.toNodeId) + } + + for (edge in edgesLeavingSelectedSet) { + val (newG, ids) = calculateDeleteEdge(g, edge) + g = newG + modifiedNodeIds.addAll(ids) + } + + g.copy( + edges = g.edges.filterNot { edge -> selectedNodeIds.contains(edge.toNodeId) }, + nodes = g.nodes.filterNot { node -> selectedNodeIds.contains(node.id) } + ) + } + validate() + return modifiedNodeIds + selectedNodeIds + } + + fun updateNodeData(nodeId: String, newData: NodeData) { + _graph.update { currentGraph -> + val oldNode = currentGraph.nodes.find { it.id == nodeId } + val oldData = oldNode?.data + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(nodeId, oldData, newData, currentGraph.edges) + + var newGraph = currentGraph.copy( + nodes = currentGraph.nodes.map { if (it.id == nodeId) it.copy(data = finalNewData) else it }, + edges = finalEdges + ) + + if (oldData != null) { + val updatedNode = newGraph.nodes.find { it.id == nodeId } + val visiblePortIds = updatedNode?.getVisiblePorts(newGraph)?.map { it.id } ?: emptyList() + + newGraph = newGraph.copy( + edges = newGraph.edges.filter { edge -> + if (edge.toNodeId == nodeId) { + edge.toPortId in visiblePortIds + } else { + true + } + } + ) + } + newGraph + } + validate() + } + + fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { + _graph.update { currentGraph -> + currentGraph.copy( + nodes = currentGraph.nodes.map { if (it.id == nodeId) it.copy(isDisabled = isDisabled) else it } + ) + } + validate() + } + + fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { + val node = _graph.value.nodes.find { it.id == nodeId } ?: return + val data = node.data + + when (data) { + is NodeData.Family -> { + val newList = data.coatPortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(coatPortIds = newList)) + } + is NodeData.Behavior -> { + if (data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val setSize = 2 + val fromSet = fromIndex / setSize + val toSet = toIndex / setSize + if (fromSet == toSet) return + + val newList = data.inputPortIds.toMutableList() + val requiredSize = maxOf(fromSet * 2 + 2, toSet * 2 + 2) + while (newList.size < requiredSize) { + newList.add("invalid_port_${newList.size}") + } + + val temp0 = newList[fromSet * 2] + val temp1 = newList[fromSet * 2 + 1] + newList[fromSet * 2] = newList[toSet * 2] + newList[fromSet * 2 + 1] = newList[toSet * 2 + 1] + newList[toSet * 2] = temp0 + newList[toSet * 2 + 1] = temp1 + + updateNodeData(nodeId, data.copy(inputPortIds = newList)) + } else { + val newList = data.inputPortIds.toMutableList() + if (fromIndex in newList.indices && toIndex in newList.indices) { + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(inputPortIds = newList)) + } + } + } + is NodeData.Paint -> { + val T = data.texturePortIds.size + + val isFromTexture = fromIndex in 0 until T + val isToTexture = toIndex in 0 until T + val isFromColor = fromIndex in (T + 1) until (T + 1 + data.colorPortIds.size) + val isToColor = toIndex in (T + 1) until (T + 1 + data.colorPortIds.size) + + if (isFromTexture && isToTexture) { + val newList = data.texturePortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(texturePortIds = newList)) + } else if (isFromColor && isToColor) { + val fromColorIndex = fromIndex - (T + 1) + val toColorIndex = toIndex - (T + 1) + val newList = data.colorPortIds.toMutableList() + val item = newList.removeAt(fromColorIndex) + newList.add(toColorIndex, item) + updateNodeData(nodeId, data.copy(colorPortIds = newList)) + } + } + is NodeData.Tip -> { + val newList = data.behaviorPortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(behaviorPortIds = newList)) + } + is NodeData.Coat -> { + val newList = data.paintPortIds.toMutableList() + val item = newList.removeAt(fromIndex - 1) // Tip is at index 0 + newList.add(toIndex - 1, item) + updateNodeData(nodeId, data.copy(paintPortIds = newList)) + } + else -> {} + } + } + + fun addNodeBetween(edge: GraphEdge): String? { + var newNodeId: String? = null + _graph.update { currentGraph -> + val fromNode = currentGraph.nodes.find { it.id == edge.fromNodeId } ?: return@update currentGraph + val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } ?: return@update currentGraph + + if (fromNode.data !is NodeData.Behavior || toNode.data !is NodeData.Behavior) { + return@update currentGraph // Only for behavior nodes! + } + + val id = UUID.randomUUID().toString() + newNodeId = id + val newPortId = UUID.randomUUID().toString() + val newNode = GraphNode( + id = id, + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setResponseNode( + ink.proto.BrushBehavior.ResponseNode.newBuilder() + .setPredefinedResponseCurve(ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR) + ) + .build(), + inputPortIds = listOf(newPortId) + ) + ) + + val edge1 = GraphEdge(fromNodeId = edge.fromNodeId, toNodeId = id, toPortId = newPortId) + val edge2 = GraphEdge(fromNodeId = id, toNodeId = edge.toNodeId, toPortId = edge.toPortId) + + val newEdges = currentGraph.edges.filter { it != edge } + edge1 + edge2 + val newNodes = currentGraph.nodes + newNode + + currentGraph.copy(nodes = newNodes, edges = newEdges) + } + validate() + return newNodeId + } + + fun reorganize(): BrushFamily? { + var family: BrushFamily? = null + var success = false + _graph.update { currentGraph -> + val clearedNodes = currentGraph.nodes.map { it.copy(hasError = false) } + val g = currentGraph.copy(nodes = clearedNodes) + + try { + val f = BrushFamilyConverter.convert(g) + family = f + success = true + BrushGraphConverter.fromBrushFamily(f) + } catch (e: Exception) { + success = false + g + } + } + validate() + if (success) { + postDebug(DisplayText.Resource(R.string.bg_msg_graph_reorganized_success)) + } else { + postDebug(DisplayText.Resource(R.string.bg_err_reorganization_failed)) + } + return family + } + + fun loadBrushFamily(family: BrushFamily): Boolean { + return try { + _graph.update { BrushGraphConverter.fromBrushFamily(family) } + validate() + postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) + true + } catch (e: Exception) { + Log.e("BrushGraph", "Failed to load brush", e) + postDebug(DisplayText.Resource(R.string.bg_err_load_brush_failed)) + false + } + } + + fun duplicateSelectedNodes(selectedNodeIds: Set): Set { + var newIds = emptySet() + _graph.update { currentGraph -> + val nodesToDuplicate = currentGraph.nodes.filter { selectedNodeIds.contains(it.id) } + val idMap = nodesToDuplicate.associate { it.id to UUID.randomUUID().toString() } + newIds = idMap.values.toSet() + + val newNodes = nodesToDuplicate.map { node -> + node.copy( + id = idMap[node.id]!! + ) + } + + val edgesToDuplicate = currentGraph.edges.filter { edge -> + selectedNodeIds.contains(edge.fromNodeId) && selectedNodeIds.contains(edge.toNodeId) + } + + val newEdges = edgesToDuplicate.map { edge -> + edge.copy( + fromNodeId = idMap[edge.fromNodeId]!!, + toNodeId = idMap[edge.toNodeId]!! + ) + } + + currentGraph.copy( + nodes = currentGraph.nodes + newNodes, + edges = currentGraph.edges + newEdges + ) + } + validate() + return newIds + } + + fun deleteNode(nodeId: String): Set { + val modifiedNodeIds = mutableSetOf() + val node = _graph.value.nodes.find { it.id == nodeId } ?: return modifiedNodeIds + if (node.data is NodeData.Family) { + postDebug(DisplayText.Resource(R.string.bg_err_cannot_delete_family_node)) + return modifiedNodeIds + } + + _graph.update { currentGraph -> + val edgesToRemove = currentGraph.edges.filter { it.fromNodeId == nodeId || it.toNodeId == nodeId } + + // Remove edges going into the node being deleted. + var newGraph = currentGraph.copy(edges = currentGraph.edges.filter { it.toNodeId != nodeId }) + + // Delete edges leaving the node being deleted via calculateDeleteEdge to trigger proper port removal in target nodes. + val edgesFromNode = edgesToRemove.filter { it.fromNodeId == nodeId } + for (edge in edgesFromNode) { + val (newG, ids) = calculateDeleteEdge(newGraph, edge) + newGraph = newG + modifiedNodeIds.addAll(ids) + } + + // Finally remove the node itself. + newGraph.copy(nodes = newGraph.nodes.filter { it.id != nodeId }) + } + + validate() + modifiedNodeIds.add(nodeId) + return modifiedNodeIds + } + + fun createDefaultGraph(): BrushGraph { + val defaultTip = ink.proto.BrushTip.getDefaultInstance() + val defaultPaint = ink.proto.BrushPaint.getDefaultInstance() + val defaultCoat = ink.proto.BrushCoat.newBuilder() + .setTip(defaultTip) + .addPaintPreferences(defaultPaint) + .build() + val defaultProto = ink.proto.BrushFamily.newBuilder() + .setInputModel( + ink.proto.BrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ink.proto.BrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + ) + .addCoats(defaultCoat) + .build() + return BrushGraphConverter.fromProtoBrushFamily(defaultProto) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt new file mode 100644 index 0000000..1de8b4d --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt @@ -0,0 +1,905 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.data + +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.data.GraphValidator +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.BrushFamilyConverter +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import ink.proto.BrushBehavior +import ink.proto.BrushTip +import ink.proto.BrushPaint +import ink.proto.BrushFamily +import com.example.cahier.R +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrushFamilyConverterTest { + + @Test + fun validateAll_disabledNonOperatorNode_isIgnored() { + val disabledNode = GraphNode( + id = "target_node", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "target_node", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, disabledNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 1 issues: $issues") + + // Target node should not report errors because it is disabled. + assertTrue(issues.none { it.nodeId == "target_node" }) + } + + @Test + fun validateAll_disabledOperatorNodeActsAsPassThrough_succeeds() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val dampingNode = GraphNode( + id = "damping", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setDampingNode(BrushBehavior.DampingNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "damping", toPortId = "Input"), + GraphEdge(fromNodeId = "damping", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, dampingNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 2 issues: $issues") + + // Should pass because Damping passes through! + assertTrue(issues.none { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun validateAll_disabledMultiInputOperatorFirstInput_succeeds() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setBinaryOpNode(BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "binary_op", toPortId = "input_0"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 3 issues: $issues") + + assertTrue(issues.none { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun validateAll_disabledMultiInputOperatorSecondInput_noMissingSourceError() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setBinaryOpNode(BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "binary_op", toPortId = "input_1"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 4 issues: $issues") + + org.junit.Assert.assertFalse(issues.any { it.message?.contains("Missing source for pass-through connection") == true }) + } + + @Test + fun validateAll_downstreamNodeDisabled_reportsUnusedOutput() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder().build()) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")), + isDisabled = true + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + + assertTrue(issues.any { it.nodeId == "tip" && it.displayMessage is DisplayText.Resource && (it.displayMessage as DisplayText.Resource).resId == R.string.bg_err_unused_output }) + assertTrue(issues.any { it.nodeId == "paint" && it.displayMessage is DisplayText.Resource && (it.displayMessage as DisplayText.Resource).resId == R.string.bg_err_unused_output }) + } + + @Test + fun convertIntoProto_startNodeReachedMultipleTimes_duplicatesSourceNode() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder() + .setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val dampingNodeA = GraphNode( + id = "dampingA", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setDampingNode(ink.proto.BrushBehavior.DampingNode.newBuilder().build()) + .build() + ) + ) + + val dampingNodeB = GraphNode( + id = "dampingB", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setDampingNode(ink.proto.BrushBehavior.DampingNode.newBuilder().build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().setOperation(ink.proto.BrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(ink.proto.BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "dampingA", toPortId = "Input"), + GraphEdge(fromNodeId = "source", toNodeId = "dampingB", toPortId = "Input"), + GraphEdge(fromNodeId = "dampingA", toNodeId = "binary_op", toPortId = "input_0"), + GraphEdge(fromNodeId = "dampingB", toNodeId = "binary_op", toPortId = "input_1"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, dampingNodeA, dampingNodeB, sourceNode), + edges = edges + ) + + val brushFamily = try { + BrushFamilyConverter.convertIntoProto(graph) + } catch (e: GraphValidationException) { + println("Validation failed: ${e.message}") + throw e + } + + val tip = brushFamily.getCoats(0).tip + org.junit.Assert.assertEquals(1, tip.behaviorsCount) + val behavior = tip.getBehaviors(0) + + val sourceNodeCount = behavior.nodesList.count { it.hasSourceNode() } + org.junit.Assert.assertEquals(2, sourceNodeCount) + } + + @Test + fun convertIntoProto_interpolationNodeWithFullSetOfInputs_createsInterpolationNode() { + val valueNode = GraphNode( + id = "value", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val startNode = GraphNode( + id = "start", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setConstantNode(ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(0f).build()) + .build() + ) + ) + + val endNode = GraphNode( + id = "end", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setConstantNode(ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(1f).build()) + .build() + ) + ) + + val lerpNode = GraphNode( + id = "lerp", + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setInterpolationNode(ink.proto.BrushBehavior.InterpolationNode.newBuilder().setInterpolation(ink.proto.BrushBehavior.Interpolation.INTERPOLATION_LERP).build()) + .build(), + inputPortIds = listOf("Value", "Start", "End") + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value", toNodeId = "lerp", toPortId = "Value"), + GraphEdge(fromNodeId = "start", toNodeId = "lerp", toPortId = "Start"), + GraphEdge(fromNodeId = "end", toNodeId = "lerp", toPortId = "End"), + GraphEdge(fromNodeId = "lerp", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, lerpNode, valueNode, startNode, endNode), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val tip = brushFamily.getCoats(0).tip + val behavior = tip.getBehaviors(0) + + org.junit.Assert.assertEquals(5, behavior.nodesCount) + org.junit.Assert.assertTrue(behavior.getNodes(3).hasInterpolationNode()) + } + + @Test + fun convertIntoProto_coatWithMultiplePaints_createsMultiplePaints() { + val valueNode = GraphNode( + id = "value", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode1 = GraphNode( + id = "paint1", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val paintNode2 = GraphNode( + id = "paint2", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0", "paint_1")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint1", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "paint2", toNodeId = "coat", toPortId = "paint_1"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode1, paintNode2, targetNode, valueNode), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + org.junit.Assert.assertEquals(2, coat.paintPreferencesCount) + } + + @Test + fun convertIntoProto_tipWithMultipleBehaviors_createsMultipleBehaviors() { + val valueNode1 = GraphNode( + id = "value1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode1 = GraphNode( + id = "target1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val valueNode2 = GraphNode( + id = "value2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_TILT_IN_RADIANS).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode2 = GraphNode( + id = "target2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("0", "1")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value1", toNodeId = "target1", toPortId = "Input"), + GraphEdge(fromNodeId = "target1", toNodeId = "tip", toPortId = "0"), + GraphEdge(fromNodeId = "value2", toNodeId = "target2", toPortId = "Input"), + GraphEdge(fromNodeId = "target2", toNodeId = "tip", toPortId = "1"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode1, targetNode2, valueNode1, valueNode2), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val tip = brushFamily.getCoats(0).tip + org.junit.Assert.assertEquals(2, tip.behaviorsCount) + } + + @Test + fun convertIntoProto_binaryOpWithMultipleInputs_chainsInputs() { + val sourceNode1 = GraphNode( + id = "source1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val sourceNode2 = GraphNode( + id = "source2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val sourceNode3 = GraphNode( + id = "source3", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binOp", + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1", "input_2") + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("Input")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source1", toNodeId = "binOp", toPortId = "input_0"), + GraphEdge(fromNodeId = "source2", toNodeId = "binOp", toPortId = "input_1"), + GraphEdge(fromNodeId = "source3", toNodeId = "binOp", toPortId = "input_2"), + GraphEdge(fromNodeId = "binOp", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode1, sourceNode2, sourceNode3), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + val tip = coat.tip + + org.junit.Assert.assertEquals(1, tip.behaviorsCount) + val behavior = tip.getBehaviors(0) + org.junit.Assert.assertEquals(6, behavior.nodesCount) + + org.junit.Assert.assertTrue(behavior.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(1).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(2).hasBinaryOpNode()) + org.junit.Assert.assertTrue(behavior.getNodes(3).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(4).hasBinaryOpNode()) + org.junit.Assert.assertTrue(behavior.getNodes(5).hasTargetNode()) + } + + @Test + fun convertIntoProto_passThroughWithMultipleInputs_propagatesInputs() { + val source1 = GraphNode( + id = "source1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val source2 = GraphNode( + id = "source2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val disabledResponse = GraphNode( + id = "disabled_response", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val target = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(ink.proto.BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("Input")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source1", toNodeId = "disabled_response", toPortId = "input_0"), + GraphEdge(fromNodeId = "source2", toNodeId = "disabled_response", toPortId = "input_1"), + GraphEdge(fromNodeId = "disabled_response", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, target, disabledResponse, source1, source2), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + val tip = coat.tip + + org.junit.Assert.assertEquals(2, tip.behaviorsCount) + + val behavior1 = tip.getBehaviors(0) + val behavior2 = tip.getBehaviors(1) + + org.junit.Assert.assertEquals(2, behavior1.nodesCount) + org.junit.Assert.assertEquals(2, behavior2.nodesCount) + + org.junit.Assert.assertTrue(behavior1.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior1.getNodes(1).hasTargetNode()) + + org.junit.Assert.assertTrue(behavior2.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior2.getNodes(1).hasTargetNode()) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt new file mode 100644 index 0000000..4c2a5a9 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt @@ -0,0 +1,227 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.data + +import com.example.cahier.developer.brushgraph.data.BrushGraphConverter +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior +import ink.proto.BrushTip +import ink.proto.BrushPaint +import ink.proto.BrushFamily +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import com.example.cahier.R +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class BrushGraphConverterTest { + + @Test + fun fromProtoBrushFamily_identicalNodes_areDeduplicated() { + val behaviorProto = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val tipProto = BrushTip.newBuilder() + .addBehaviors(behaviorProto) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + } + @Test + fun fromProtoBrushFamily_identicalNodesAcrossBehaviors_areDeduplicated() { + val behaviorProto1 = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val behaviorProto2 = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val tipProto = BrushTip.newBuilder() + .addBehaviors(behaviorProto1) + .addBehaviors(behaviorProto2) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + + val tipNode = graph.nodes.find { it.data is NodeData.Tip }!! + val edgesToTip = graph.edges.filter { it.toNodeId == tipNode.id } + assertEquals(1, edgesToTip.size) + + val tipData = tipNode.data as NodeData.Tip + assertEquals(1, tipData.behaviorPortIds.size) + } + + @Test + fun fromProtoBrushFamily_polarTargetNodes_areDeduplicated() { + val sourceNode = ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()) + .build() + + val polarTargetNode = ink.proto.BrushBehavior.Node.newBuilder() + .setPolarTargetNode(ink.proto.BrushBehavior.PolarTargetNode.newBuilder().build()) + .build() + + val behaviorProto1 = ink.proto.BrushBehavior.newBuilder() + .addNodes(sourceNode) + .addNodes(sourceNode) + .addNodes(polarTargetNode) + .build() + + val tipProto = ink.proto.BrushTip.newBuilder() + .addBehaviors(behaviorProto1) + .addBehaviors(behaviorProto1) + .build() + + val familyProto = ink.proto.BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + } + @Test + fun fromProtoBrushFamily_textureLayerNodes_areDeduplicated() { + val textureLayer1 = BrushPaint.TextureLayer.newBuilder() + .setClientTextureId("texture_1") + .build() + + val paintProto1 = BrushPaint.newBuilder() + .addTextureLayers(textureLayer1) + .build() + + val paintProto2 = BrushPaint.newBuilder() + .addTextureLayers(textureLayer1) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().addPaintPreferences(paintProto1).addPaintPreferences(paintProto2).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val textureNodes = graph.nodes.filter { it.data is NodeData.TextureLayer } + assertEquals(1, textureNodes.size) + } + + @Test + fun fromProtoBrushFamily_colorFunctionNodes_areDeduplicated() { + val colorFunction1 = ink.proto.ColorFunction.getDefaultInstance() + + val paintProto1 = BrushPaint.newBuilder() + .addColorFunctions(colorFunction1) + .build() + + val paintProto2 = BrushPaint.newBuilder() + .addColorFunctions(colorFunction1) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().addPaintPreferences(paintProto1).addPaintPreferences(paintProto2).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val colorNodes = graph.nodes.filter { it.data is NodeData.ColorFunction } + assertEquals(1, colorNodes.size) + } + + @Test + fun fromProtoBrushFamily_allCustomBrushesRoundTrip_preservesContent() { + val brushResources = listOf( + R.raw.calligraphy, + R.raw.flag_banner, + R.raw.graffiti, + R.raw.groovy, + R.raw.holiday_lights, + R.raw.lace, + R.raw.music, + R.raw.shadow, + R.raw.twisted_yarn, + R.raw.wet_paint + ) + + for (resId in brushResources) { + val stream = RuntimeEnvironment.getApplication().resources.openRawResource(resId) + val gis = java.util.zip.GZIPInputStream(stream) + val originalProto = ink.proto.BrushFamily.parseFrom(gis) + + val graph = BrushGraphConverter.fromProtoBrushFamily(originalProto) + val roundTrippedProto = BrushFamilyConverter.convertIntoProto(graph) + + val resName = RuntimeEnvironment.getApplication().resources.getResourceEntryName(resId) + + // Won't be identical, but we check for rough functional equivalency + assertEquals("Coats count mismatch for $resName", originalProto.coatsCount, roundTrippedProto.coatsCount) + assertEquals("Client ID mismatch for $resName", originalProto.clientBrushFamilyId, roundTrippedProto.clientBrushFamilyId) + + for (i in 0 until originalProto.coatsCount) { + val originalCoat = originalProto.getCoats(i) + val roundTrippedCoat = roundTrippedProto.getCoats(i) + + val originalTargets = collectTargets(originalCoat.tip) + val roundTrippedTargets = collectTargets(roundTrippedCoat.tip) + + assertEquals("Targets mismatch for brush resource $resName coat $i", originalTargets, roundTrippedTargets) + + assertEquals("Paint preferences mismatch for brush resource $resName coat $i", originalCoat.paintPreferencesList, roundTrippedCoat.paintPreferencesList) + } + } + } + + private fun collectTargets(tip: ink.proto.BrushTip): Set { + val targets = mutableSetOf() + for (behavior in tip.behaviorsList) { + for (node in behavior.nodesList) { + if (node.hasTargetNode()) { + targets.add(node.targetNode.toString().trim()) + } else if (node.hasPolarTargetNode()) { + targets.add(node.polarTargetNode.toString().trim()) + } + } + } + return targets + } +} From 9064702f1c520de991c9ae6fa45eb24b7f344074 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:53:22 +0000 Subject: [PATCH 4/9] Gemini code review comments --- .../com/example/cahier/core/di/AppModule.kt | 15 +++ .../brushdesigner/data/CustomBrushDao.kt | 9 +- .../brushgraph/data/BrushFamilyConverter.kt | 96 ++++++++++--------- .../brushgraph/data/BrushGraphConverter.kt | 36 ++++--- .../brushgraph/data/BrushGraphRepository.kt | 10 +- 5 files changed, 94 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/example/cahier/core/di/AppModule.kt b/app/src/main/java/com/example/cahier/core/di/AppModule.kt index 5c735f3..a6b858b 100644 --- a/app/src/main/java/com/example/cahier/core/di/AppModule.kt +++ b/app/src/main/java/com/example/cahier/core/di/AppModule.kt @@ -34,11 +34,26 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope @Module @InstallIn(SingletonComponent::class) object AppModule { + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + @Provides @Singleton fun provideNoteDatabase(@ApplicationContext context: Context): NoteDatabase { diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt index 303b5fa..315d9f7 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt @@ -24,10 +24,15 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow +public const val AUTOSAVE_KEY = "__autosave__" + @Dao interface CustomBrushDao { - @Query("SELECT * FROM custom_brushes") - fun getAllCustomBrushes(): Flow> + @Query("SELECT * FROM custom_brushes WHERE name != :autosaveKey") + fun getAllCustomBrushes(autosaveKey: String = AUTOSAVE_KEY): Flow> + + @Query("SELECT * FROM custom_brushes WHERE name = :autosaveKey") + fun getAutoSaveBrush(autosaveKey: String = AUTOSAVE_KEY): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveCustomBrush(brush: CustomBrushEntity) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt index e39ddd2..77c4bff 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt @@ -34,6 +34,13 @@ import ink.proto.BrushPaint as ProtoBrushPaint import ink.proto.BrushTip as ProtoBrushTip import ink.proto.ColorFunction as ProtoColorFunction +private class ConversionContext( + val graph: BrushGraph, + val nodesById: Map, + val edgesByToNode: Map>, + val behaviorCache: MutableMap>> = mutableMapOf() +) + /** Utility to convert a [BrushGraph] data model into a functional [BrushFamily] object. */ object BrushFamilyConverter { @@ -54,10 +61,14 @@ object BrushFamilyConverter { throw criticalErrors.first() } + val nodesById = graph.nodes.associateBy { it.id } + val edgesByToNode = graph.edges.filter { !it.isDisabled }.groupBy { it.toNodeId } + val context = ConversionContext(graph, nodesById, edgesByToNode) + val familyNode = graph.nodes.first { it.data is NodeData.Family } val familyData = familyNode.data as NodeData.Family - val coatEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == familyNode.id } + val coatEdges = context.edgesByToNode[familyNode.id] ?: emptyList() val sortedCoatEdges = familyData.coatPortIds.mapNotNull { portId -> coatEdges.find { it.toPortId == portId } } @@ -68,13 +79,11 @@ object BrushFamilyConverter { ) } - val behaviorCache = mutableMapOf>>() val coats = sortedCoatEdges.mapNotNull { edge -> - val coatNode = - graph.nodes.find { it.id == edge.fromNodeId } + val coatNode = context.nodesById[edge.fromNodeId] ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_node_not_found, listOf(edge.fromNodeId))) if (coatNode.isDisabled) null - else createCoat(coatNode, graph, behaviorCache) + else createCoat(coatNode, context) } return ProtoBrushFamily.newBuilder() @@ -85,12 +94,11 @@ object BrushFamilyConverter { .build() } - fun createCoat( + private fun createCoat( coatNode: GraphNode, - graph: BrushGraph, - behaviorCache: MutableMap>>, + context: ConversionContext ): ProtoBrushCoat { - val inputs = graph.edges.filter { !it.isDisabled && it.toNodeId == coatNode.id } + val inputs = context.edgesByToNode[coatNode.id] ?: emptyList() val coatData = coatNode.data as NodeData.Coat val tipEdge = @@ -110,13 +118,13 @@ object BrushFamilyConverter { ) } - val tip = createTip(tipEdge.fromNodeId, graph, behaviorCache, mutableSetOf()) + val tip = createTip(tipEdge.fromNodeId, context, mutableSetOf()) val builder = ProtoBrushCoat.newBuilder() .setTip(tip) for (edge in paintEdges) { - val paint = createPaint(edge.fromNodeId, graph) + val paint = createPaint(edge.fromNodeId, context) builder.addPaintPreferences(paint) } @@ -125,12 +133,10 @@ object BrushFamilyConverter { private fun createTip( nodeId: String, - graph: BrushGraph, - behaviorCache: MutableMap>>, + context: ConversionContext, path: MutableSet, ): ProtoBrushTip { - val graphNode = - graph.nodes.find { it.id == nodeId } + val graphNode = context.nodesById[nodeId] ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) val data = graphNode.data as? NodeData.Tip @@ -143,12 +149,12 @@ object BrushFamilyConverter { builder.clearBehaviors() val behaviorEdges = data.behaviorPortIds.mapNotNull { portId -> - graph.edges.find { !it.isDisabled && it.toNodeId == nodeId && it.toPortId == portId } + context.edgesByToNode[nodeId]?.find { it.toPortId == portId } } for (edge in behaviorEdges) { - val actualSources = GraphValidator.findActualSourceNode(graph, edge.fromNodeId) + val actualSources = GraphValidator.findActualSourceNode(context.graph, edge.fromNodeId) for (actualSourceNode in actualSources) { - val behaviorLists = collectBehaviorNodes(actualSourceNode.id, graph, behaviorCache, path) + val behaviorLists = collectBehaviorNodes(actualSourceNode.id, context, path) for (nodeList in behaviorLists) { val comment = (actualSourceNode.data as? NodeData.Behavior)?.developerComment ?: "" builder.addBehaviors( @@ -166,18 +172,17 @@ object BrushFamilyConverter { private fun collectBehaviorNodes( nodeId: String, - graph: BrushGraph, - cache: MutableMap>>, + context: ConversionContext, path: MutableSet, ): List> { if (path.contains(nodeId)) { throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_cycle_detected, listOf(nodeId)), nodeId = nodeId) } - cache[nodeId]?.let { return it } + context.behaviorCache[nodeId]?.let { return it } - val graphNode = graph.nodes.find { it.id == nodeId } ?: return emptyList() + val graphNode = context.nodesById[nodeId] ?: return emptyList() val data = graphNode.data as? NodeData.Behavior ?: return emptyList() - val inputEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + val inputEdges = context.edgesByToNode[nodeId] ?: emptyList() path.add(nodeId) val resultLists = mutableListOf>() @@ -208,13 +213,13 @@ object BrushFamilyConverter { val setLists = mutableListOf>>() for (edge in sortedEdges) { - val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + val sources = edge?.let { GraphValidator.findActualSourceNode(context.graph, it.fromNodeId) } ?: emptyList() val lists = mutableListOf>() if (sources.isEmpty()) { lists.add(listOf(createDefaultNode())) } else { for (source in sources) { - lists.addAll(collectBehaviorNodes(source.id, graph, cache, path)) + lists.addAll(collectBehaviorNodes(source.id, context, path)) } } setLists.add(lists) @@ -253,13 +258,13 @@ object BrushFamilyConverter { for (set in chunkedEdges) { val setLists = mutableListOf>>() for (edge in set) { - val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + val sources = edge?.let { GraphValidator.findActualSourceNode(context.graph, it.fromNodeId) } ?: emptyList() val lists = mutableListOf>() if (sources.isEmpty()) { lists.add(listOf(createDefaultNode())) } else { for (src in sources) { - lists.addAll(collectBehaviorNodes(src.id, graph, cache, path)) + lists.addAll(collectBehaviorNodes(src.id, context, path)) } } setLists.add(lists) @@ -284,10 +289,10 @@ object BrushFamilyConverter { resultLists.add(listOf(data.node)) } else { for (edge in sortedEdges) { - val sources = edge?.let { GraphValidator.findActualSourceNode(graph, it.fromNodeId) } ?: emptyList() + val sources = edge?.let { GraphValidator.findActualSourceNode(context.graph, it.fromNodeId) } ?: emptyList() if (sources.isNotEmpty()) { for (source in sources) { - val childLists = collectBehaviorNodes(source.id, graph, cache, path) + val childLists = collectBehaviorNodes(source.id, context, path) for (childList in childLists) { val newList = mutableListOf() newList.addAll(childList) @@ -304,13 +309,12 @@ object BrushFamilyConverter { } path.remove(nodeId) - cache[nodeId] = resultLists + context.behaviorCache[nodeId] = resultLists return resultLists } - private fun createPaint(nodeId: String, graph: BrushGraph): ProtoBrushPaint { - val graphNode = - graph.nodes.find { it.id == nodeId } + private fun createPaint(nodeId: String, context: ConversionContext): ProtoBrushPaint { + val graphNode = context.nodesById[nodeId] ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) val data = graphNode.data as? NodeData.Paint @@ -320,17 +324,17 @@ object BrushFamilyConverter { ) val textureEdges = data.texturePortIds.mapNotNull { portId -> - graph.edges.find { edge -> - if (edge.isDisabled || edge.toNodeId != nodeId || edge.toPortId != portId) return@find false - val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + context.edgesByToNode[nodeId]?.find { edge -> + if (edge.toPortId != portId) return@find false + val fromNode = context.nodesById[edge.fromNodeId] fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.TextureLayer } } val colorEdges = data.colorPortIds.mapNotNull { portId -> - graph.edges.find { edge -> - if (edge.isDisabled || edge.toNodeId != nodeId || edge.toPortId != portId) return@find false - val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + context.edgesByToNode[nodeId]?.find { edge -> + if (edge.toPortId != portId) return@find false + val fromNode = context.nodesById[edge.fromNodeId] fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.ColorFunction } } @@ -340,18 +344,17 @@ object BrushFamilyConverter { builder.clearColorFunctions() for (edge in textureEdges) { - builder.addTextureLayers(createTextureLayer(edge.fromNodeId, graph)) + builder.addTextureLayers(createTextureLayer(edge.fromNodeId, context)) } for (edge in colorEdges) { - builder.addColorFunctions(createColorFunction(edge.fromNodeId, graph)) + builder.addColorFunctions(createColorFunction(edge.fromNodeId, context)) } return builder.build() } - private fun createTextureLayer(nodeId: String, graph: BrushGraph): ProtoBrushPaint.TextureLayer { - val graphNode = - graph.nodes.find { it.id == nodeId } + private fun createTextureLayer(nodeId: String, context: ConversionContext): ProtoBrushPaint.TextureLayer { + val graphNode = context.nodesById[nodeId] ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) val data = graphNode.data as? NodeData.TextureLayer @@ -362,9 +365,8 @@ object BrushFamilyConverter { return data.layer } - private fun createColorFunction(nodeId: String, graph: BrushGraph): ProtoColorFunction { - val graphNode = - graph.nodes.find { it.id == nodeId } + private fun createColorFunction(nodeId: String, context: ConversionContext): ProtoColorFunction { + val graphNode = context.nodesById[nodeId] ?: throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_not_found, listOf(nodeId))) val data = graphNode.data as? NodeData.ColorFunction diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt index 5a83c9b..0d8f1d2 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt @@ -331,18 +331,20 @@ object BrushGraphConverter { private fun deduplicateDownstream(graph: BrushGraph): BrushGraph { val nodes = graph.nodes.toMutableList() val edges = graph.edges.toMutableList() + val nodesById = nodes.associateBy { it.id }.toMutableMap() // Filter and reverse behavior nodes to process top-down val behaviorNodes = nodes.filter { it.data is NodeData.Behavior }.reversed() val removedNodeIds = mutableSetOf() + val processedNodes = mutableMapOf>, GraphNode>() for (node in behaviorNodes) { if (removedNodeIds.contains(node.id)) continue val nodeData = node.data as NodeData.Behavior - val isInterpolation = nodeData.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE - val isBinaryOp = nodeData.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE + val isInterpolation = nodeData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE + val isBinaryOp = nodeData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE if (isInterpolation || isBinaryOp) continue val nodeOutputSet = edges.filter { it.fromNodeId == node.id && !it.isDisabled } @@ -351,29 +353,22 @@ object BrushGraphConverter { if (nodeOutputSet.isEmpty()) continue - // Find another node to merge with - val otherNode = nodes.find { other -> - if (other.id == node.id || removedNodeIds.contains(other.id)) return@find false - val otherData = other.data as? NodeData.Behavior ?: return@find false - if (otherData.node != nodeData.node) return@find false - - val otherOutputSet = edges.filter { it.fromNodeId == other.id && !it.isDisabled } - .map { it.toNodeId } - .sorted() - otherOutputSet == nodeOutputSet - } + val key = Pair(nodeData.node, nodeOutputSet) + val existingNode = processedNodes[key] - if (otherNode != null) { - val keptNode = otherNode + if (existingNode != null) { + val keptNode = existingNode val nodeToRemove = node val keptData = keptNode.data as NodeData.Behavior - val dataToRemove = nodeToRemove.data as NodeData.Behavior - val newData = keptData.copy(inputPortIds = keptData.inputPortIds + dataToRemove.inputPortIds) + val newData = keptData.copy(inputPortIds = keptData.inputPortIds + nodeData.inputPortIds) nodes.remove(keptNode) val updatedKeptNode = keptNode.copy(data = newData) nodes.add(updatedKeptNode) + nodesById[keptNode.id] = updatedKeptNode + + processedNodes[key] = updatedKeptNode // Redirect incoming edges val incomingEdges = edges.filter { it.toNodeId == nodeToRemove.id } @@ -385,18 +380,21 @@ object BrushGraphConverter { // Remove outgoing edges of removed node and cleanup ports! val outgoingEdges = edges.filter { it.fromNodeId == nodeToRemove.id } for (edge in outgoingEdges) { - val parentNode = nodes.find { it.id == edge.toNodeId } + val parentNode = nodesById[edge.toNodeId] if (parentNode != null) { val updatedParent = removePortFromNode(parentNode, edge.toPortId) nodes.remove(parentNode) nodes.add(updatedParent) + nodesById[parentNode.id] = updatedParent } } edges.removeAll(outgoingEdges) - // Remove node from list nodes.remove(nodeToRemove) + nodesById.remove(nodeToRemove.id) removedNodeIds.add(nodeToRemove.id) + } else { + processedNodes[key] = node } } return BrushGraph(nodes = nodes, edges = edges) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt index a7c21f4..fd8b1ff 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -20,6 +20,7 @@ import android.util.Log import com.example.cahier.R import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.BrushFamily import androidx.ink.storage.AndroidBrushFamilySerialization @@ -30,6 +31,8 @@ import java.util.UUID import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction import javax.inject.Inject import javax.inject.Singleton +import com.example.cahier.core.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope import kotlin.OptIn import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -45,12 +48,11 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) class BrushGraphRepository @Inject constructor( private val customBrushDao: CustomBrushDao, - val textureStore: CahierTextureBitmapStore + val textureStore: CahierTextureBitmapStore, + @ApplicationScope private val scope: CoroutineScope ) { private val _graph = MutableStateFlow(createDefaultGraph()) val graph: StateFlow = _graph.asStateFlow() - - private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO) init { scope.launch { graph @@ -61,7 +63,7 @@ class BrushGraphRepository @Inject constructor( val family = BrushFamilyConverter.convert(graph) val baos = ByteArrayOutputStream() AndroidBrushFamilySerialization.encode(family, baos, textureStore) - customBrushDao.saveCustomBrush(com.example.cahier.developer.brushdesigner.data.CustomBrushEntity("__autosave__", baos.toByteArray())) + customBrushDao.saveCustomBrush(com.example.cahier.developer.brushdesigner.data.CustomBrushEntity(AUTOSAVE_KEY, baos.toByteArray())) } catch (e: Exception) { android.util.Log.e("BrushGraphRepository", "Failed to auto-save brush", e) } From a415aa63fa484e2adee99f906d1901879fc2070b Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:28:09 +0000 Subject: [PATCH 5/9] Add support for caching last valid brush family + fix warnings on test --- .../developer/brushgraph/data/BrushGraphRepository.kt | 11 +++++++---- .../brushgraph/data/BrushFamilyConverterTest.kt | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt index fd8b1ff..038cc74 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -71,6 +71,7 @@ class BrushGraphRepository @Inject constructor( } } + private var _lastValidBrushFamily: BrushFamily? = null private val _graphIssues = MutableStateFlow>(emptyList()) val graphIssues: StateFlow> = _graphIssues.asStateFlow() @@ -138,16 +139,18 @@ class BrushGraphRepository @Inject constructor( } } - fun getBrushFamily(): androidx.ink.brush.BrushFamily? { - if (!validate()) return null + fun getBrushFamily(): BrushFamily? { + if (!validate()) return _lastValidBrushFamily return try { - BrushFamilyConverter.convert(_graph.value) + val family = BrushFamilyConverter.convert(_graph.value) + _lastValidBrushFamily = family + family } catch (e: Exception) { val internalError = GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_internal_conversion, listOf(e.message ?: e.javaClass.simpleName))) _graphIssues.update { currentIssues -> (currentIssues + internalError).distinctBy { issue -> Triple(issue.displayMessage, issue.nodeId, issue.severity) } } - null + _lastValidBrushFamily } } diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt index 1de8b4d..eb325aa 100644 --- a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt @@ -364,8 +364,8 @@ class BrushFamilyConverterTest { val issues = GraphValidator.validateAll(graph) - assertTrue(issues.any { it.nodeId == "tip" && it.displayMessage is DisplayText.Resource && (it.displayMessage as DisplayText.Resource).resId == R.string.bg_err_unused_output }) - assertTrue(issues.any { it.nodeId == "paint" && it.displayMessage is DisplayText.Resource && (it.displayMessage as DisplayText.Resource).resId == R.string.bg_err_unused_output }) + assertTrue(issues.any { it.nodeId == "tip" && it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_unused_output }) + assertTrue(issues.any { it.nodeId == "paint" && it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_unused_output }) } @Test From b1fb41a841f04a8ac9035235ba4be65f2123740a Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:49:40 +0000 Subject: [PATCH 6/9] Support duplication respecting original nodes positioning --- .../developer/brushgraph/data/BrushGraphRepository.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt index 038cc74..385da99 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -564,6 +564,7 @@ class BrushGraphRepository @Inject constructor( _graph.update { BrushGraphConverter.fromBrushFamily(family) } validate() postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) + _lastValidBrushFamily = family true } catch (e: Exception) { Log.e("BrushGraph", "Failed to load brush", e) @@ -572,12 +573,11 @@ class BrushGraphRepository @Inject constructor( } } - fun duplicateSelectedNodes(selectedNodeIds: Set): Set { - var newIds = emptySet() + fun duplicateSelectedNodes(selectedNodeIds: Set): Map { + var idMap = emptyMap() _graph.update { currentGraph -> val nodesToDuplicate = currentGraph.nodes.filter { selectedNodeIds.contains(it.id) } - val idMap = nodesToDuplicate.associate { it.id to UUID.randomUUID().toString() } - newIds = idMap.values.toSet() + idMap = nodesToDuplicate.associate { it.id to UUID.randomUUID().toString() } val newNodes = nodesToDuplicate.map { node -> node.copy( @@ -602,7 +602,7 @@ class BrushGraphRepository @Inject constructor( ) } validate() - return newIds + return idMap } fun deleteNode(nodeId: String): Set { From 082228a4bb3e2530c0f77e3a5fda9afc4b1dfc5e Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 14:13:19 +0000 Subject: [PATCH 7/9] Respond to Chris' comments add interface + test for repo --- .../brushgraph/data/BrushGraphRepository.kt | 86 +++-- .../data/BrushGraphRepositoryTest.kt | 338 ++++++++++++++++++ 2 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt index 385da99..b20b855 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -44,15 +44,47 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +interface BrushGraphRepository { + val graph: StateFlow + val graphIssues: StateFlow> + + fun setGraph(newGraph: BrushGraph) + fun clearGraph() + fun getBrushFamily(): BrushFamily? + fun reorganize(): BrushFamily? + + fun postDebug(displayText: DisplayText) + fun validate(): Boolean + fun clearIssues() + + suspend fun loadAutoSaveBrush(): Boolean + fun loadBrushFamily(family: BrushFamily): Boolean + + fun createDefaultGraph(): BrushGraph + fun addNode(data: NodeData): String + fun updateNodeData(nodeId: String, newData: NodeData) + fun setNodeDisabled(nodeId: String, isDisabled: Boolean) + fun deleteNode(nodeId: String): Set + fun deleteSelectedNodes(selectedNodeIds: Set): Set + fun duplicateSelectedNodes(selectedNodeIds: Set): Map + + fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) + fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge + fun deleteEdge(edge: GraphEdge): Set + fun addNodeBetween(edge: GraphEdge): String? + + fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) +} + @Singleton @OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) -class BrushGraphRepository @Inject constructor( +class DefaultBrushGraphRepository @Inject constructor( private val customBrushDao: CustomBrushDao, val textureStore: CahierTextureBitmapStore, @ApplicationScope private val scope: CoroutineScope -) { +) : BrushGraphRepository { private val _graph = MutableStateFlow(createDefaultGraph()) - val graph: StateFlow = _graph.asStateFlow() + override val graph: StateFlow = _graph.asStateFlow() init { scope.launch { graph @@ -65,7 +97,7 @@ class BrushGraphRepository @Inject constructor( AndroidBrushFamilySerialization.encode(family, baos, textureStore) customBrushDao.saveCustomBrush(com.example.cahier.developer.brushdesigner.data.CustomBrushEntity(AUTOSAVE_KEY, baos.toByteArray())) } catch (e: Exception) { - android.util.Log.e("BrushGraphRepository", "Failed to auto-save brush", e) + android.util.Log.e("DefaultBrushGraphRepository", "Failed to auto-save brush", e) } } } @@ -73,24 +105,24 @@ class BrushGraphRepository @Inject constructor( private var _lastValidBrushFamily: BrushFamily? = null private val _graphIssues = MutableStateFlow>(emptyList()) - val graphIssues: StateFlow> = _graphIssues.asStateFlow() + override val graphIssues: StateFlow> = _graphIssues.asStateFlow() - fun setGraph(newGraph: BrushGraph) { + override fun setGraph(newGraph: BrushGraph) { _graph.update { newGraph } } - fun clearGraph() { + override fun clearGraph() { _graph.update { createDefaultGraph() } validate() postDebug(DisplayText.Resource(R.string.bg_msg_graph_cleared)) } - fun postDebug(displayText: DisplayText) { + override fun postDebug(displayText: DisplayText) { val newIssue = GraphValidationException(displayMessage = displayText, severity = ValidationSeverity.DEBUG) _graphIssues.update { (it + newIssue).distinctBy { issue -> Triple(issue.displayMessage, issue.nodeId, issue.severity) } } } - fun validate(): Boolean { + override fun validate(): Boolean { val issues = GraphValidator.validateAll(_graph.value).toMutableList() val errorNodeIds = @@ -113,11 +145,11 @@ class BrushGraphRepository @Inject constructor( return issues.none { it.severity == ValidationSeverity.ERROR } } - fun clearIssues() { + override fun clearIssues() { _graphIssues.value = emptyList() } - suspend fun loadAutoSaveBrush(): Boolean { + override suspend fun loadAutoSaveBrush(): Boolean { val entity = customBrushDao.getAutoSaveBrush().firstOrNull() ?: return false val decodedBytes = entity.brushBytes return try { @@ -134,12 +166,12 @@ class BrushGraphRepository @Inject constructor( loadBrushFamily(family) true } catch (e: Exception) { - android.util.Log.e("BrushGraphRepository", "Failed to decode auto saved brush family", e) + android.util.Log.e("DefaultBrushGraphRepository", "Failed to decode auto saved brush family", e) false } } - fun getBrushFamily(): BrushFamily? { + override fun getBrushFamily(): BrushFamily? { if (!validate()) return _lastValidBrushFamily return try { val family = BrushFamilyConverter.convert(_graph.value) @@ -154,14 +186,14 @@ class BrushGraphRepository @Inject constructor( } } - fun addNode(data: NodeData): String { + override fun addNode(data: NodeData): String { val newNode = GraphNode(id = UUID.randomUUID().toString(), data = data) _graph.update { it.copy(nodes = it.nodes + newNode) } validate() return newNode.id } - fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { + override fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { var toPortId = initialToPortId if (fromNodeId == toNodeId) return @@ -233,7 +265,7 @@ class BrushGraphRepository @Inject constructor( validate() } - fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge { + override fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge { val updatedEdge = edge.copy(isDisabled = isDisabled) _graph.update { currentGraph -> currentGraph.copy( @@ -247,7 +279,7 @@ class BrushGraphRepository @Inject constructor( return updatedEdge } - fun deleteEdge(edge: GraphEdge): Set { + override fun deleteEdge(edge: GraphEdge): Set { var modifiedNodeIds = emptySet() _graph.update { currentGraph -> val (newGraph, ids) = calculateDeleteEdge(currentGraph, edge) @@ -353,7 +385,7 @@ class BrushGraphRepository @Inject constructor( return Pair(currentGraph, emptySet()) } - fun deleteSelectedNodes(selectedNodeIds: Set): Set { + override fun deleteSelectedNodes(selectedNodeIds: Set): Set { val modifiedNodeIds = mutableSetOf() _graph.update { currentGraph -> var g = currentGraph @@ -376,7 +408,7 @@ class BrushGraphRepository @Inject constructor( return modifiedNodeIds + selectedNodeIds } - fun updateNodeData(nodeId: String, newData: NodeData) { + override fun updateNodeData(nodeId: String, newData: NodeData) { _graph.update { currentGraph -> val oldNode = currentGraph.nodes.find { it.id == nodeId } val oldData = oldNode?.data @@ -407,7 +439,7 @@ class BrushGraphRepository @Inject constructor( validate() } - fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { + override fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { _graph.update { currentGraph -> currentGraph.copy( nodes = currentGraph.nodes.map { if (it.id == nodeId) it.copy(isDisabled = isDisabled) else it } @@ -416,7 +448,7 @@ class BrushGraphRepository @Inject constructor( validate() } - fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { + override fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { val node = _graph.value.nodes.find { it.id == nodeId } ?: return val data = node.data @@ -495,7 +527,7 @@ class BrushGraphRepository @Inject constructor( } } - fun addNodeBetween(edge: GraphEdge): String? { + override fun addNodeBetween(edge: GraphEdge): String? { var newNodeId: String? = null _graph.update { currentGraph -> val fromNode = currentGraph.nodes.find { it.id == edge.fromNodeId } ?: return@update currentGraph @@ -533,7 +565,7 @@ class BrushGraphRepository @Inject constructor( return newNodeId } - fun reorganize(): BrushFamily? { + override fun reorganize(): BrushFamily? { var family: BrushFamily? = null var success = false _graph.update { currentGraph -> @@ -559,7 +591,7 @@ class BrushGraphRepository @Inject constructor( return family } - fun loadBrushFamily(family: BrushFamily): Boolean { + override fun loadBrushFamily(family: BrushFamily): Boolean { return try { _graph.update { BrushGraphConverter.fromBrushFamily(family) } validate() @@ -573,7 +605,7 @@ class BrushGraphRepository @Inject constructor( } } - fun duplicateSelectedNodes(selectedNodeIds: Set): Map { + override fun duplicateSelectedNodes(selectedNodeIds: Set): Map { var idMap = emptyMap() _graph.update { currentGraph -> val nodesToDuplicate = currentGraph.nodes.filter { selectedNodeIds.contains(it.id) } @@ -605,7 +637,7 @@ class BrushGraphRepository @Inject constructor( return idMap } - fun deleteNode(nodeId: String): Set { + override fun deleteNode(nodeId: String): Set { val modifiedNodeIds = mutableSetOf() val node = _graph.value.nodes.find { it.id == nodeId } ?: return modifiedNodeIds if (node.data is NodeData.Family) { @@ -636,7 +668,7 @@ class BrushGraphRepository @Inject constructor( return modifiedNodeIds } - fun createDefaultGraph(): BrushGraph { + override fun createDefaultGraph(): BrushGraph { val defaultTip = ink.proto.BrushTip.getDefaultInstance() val defaultPaint = ink.proto.BrushPaint.getDefaultInstance() val defaultCoat = ink.proto.BrushCoat.newBuilder() diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt new file mode 100644 index 0000000..12b31f0 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt @@ -0,0 +1,338 @@ +package com.example.cahier.developer.brushgraph.data + +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.BrushFamily +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.core.ui.CahierTextureBitmapStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlinx.coroutines.cancel +import java.io.ByteArrayOutputStream +import androidx.ink.storage.AndroidBrushFamilySerialization +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.* +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushTip as ProtoBrushTip + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalCoroutinesApi::class) +class BrushGraphRepositoryTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var fakeDao: FakeCustomBrushDao + private lateinit var mockTextureStore: CahierTextureBitmapStore + private lateinit var repository: DefaultBrushGraphRepository + private lateinit var repoScope: kotlinx.coroutines.CoroutineScope + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeDao = FakeCustomBrushDao() + mockTextureStore = mock(CahierTextureBitmapStore::class.java) + repoScope = kotlinx.coroutines.CoroutineScope(testDispatcher + Job()) + repository = DefaultBrushGraphRepository(fakeDao, mockTextureStore, repoScope) + } + + @After + fun tearDown() { + repoScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun initialState_isDefaultGraph() = testScope.runTest { + val graph = repository.graph.first() + assertNotNull(graph) + assertTrue(graph.nodes.any { it.data is NodeData.Family }) + } + + @Test + fun addNode_updatesGraph() = testScope.runTest { + val initialNodeCount = repository.graph.first().nodes.size + + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val updatedGraph = repository.graph.first() + assertEquals(initialNodeCount + 1, updatedGraph.nodes.size) + assertTrue(updatedGraph.nodes.any { it.id == nodeId }) + } + + @Test + fun deleteNode_updatesGraph() = testScope.runTest { + val initialNodeCount = repository.graph.first().nodes.size + + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val graphAfterAdd = repository.graph.first() + assertEquals(initialNodeCount + 1, graphAfterAdd.nodes.size) + assertTrue(graphAfterAdd.nodes.any { it.id == nodeId }) + + repository.deleteNode(nodeId) + + val graphAfterDelete = repository.graph.first() + assertEquals(initialNodeCount, graphAfterDelete.nodes.size) + assertFalse(graphAfterDelete.nodes.any { it.id == nodeId }) + } + + @Test + fun validate_detectsWarnings() = testScope.runTest { + val behaviorNode = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("input1", "input2") + ) + repository.addNode(behaviorNode) + + // Orphaned nodes result in warnings, not errors, so the graph is still technically valid. + assertTrue(repository.validate()) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.severity == ValidationSeverity.WARNING }) + } + + @Test + fun validate_detectsErrors() = testScope.runTest { + val coatId = repository.graph.first().nodes.find { it.data is NodeData.Coat } + repository.deleteNode(coatId?.id!!) + + // No coat on the family is an error + assertFalse(repository.validate()) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun setGraph_updatesGraph() = testScope.runTest { + val newGraph = BrushGraph(nodes = listOf(GraphNode(id = "node1", data = NodeData.Family()))) + repository.setGraph(newGraph) + assertEquals(newGraph, repository.graph.first()) + } + + @Test + fun clearGraph_resetsToDefault() = testScope.runTest { + assertTrue(repository.graph.first().nodes.size > 1) + + val newGraph = BrushGraph(nodes = listOf(GraphNode(id = "node1", data = NodeData.Family()))) + repository.setGraph(newGraph) + + assertEquals(repository.graph.first().nodes.size, 1) + + repository.clearGraph() + + val graph = repository.graph.first() + assertTrue(graph.nodes.size > 1) + } + + @Test + fun postDebug_addsIssue() = testScope.runTest { + assertTrue(repository.graphIssues.first().isEmpty()) + + val text = DisplayText.Literal("debug message") + repository.postDebug(text) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.displayMessage == text && it.severity == ValidationSeverity.DEBUG }) + } + + @Test + fun clearIssues_removesIssues() = testScope.runTest { + repository.postDebug(DisplayText.Literal("debug")) + assertTrue(repository.graphIssues.first().isNotEmpty()) + + repository.clearIssues() + assertTrue(repository.graphIssues.first().isEmpty()) + } + + @Test + fun addEdge_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + + repository.addEdge(node1, node2, "tip") + + val graph = repository.graph.first() + assertTrue(graph.edges.any { it.fromNodeId == node1 && it.toNodeId == node2 && it.toPortId == "tip" }) + } + + @Test + fun deleteEdge_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + repository.addEdge(node1, node2, "tip") + + val edge = repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + + assertTrue(repository.graph.first().edges.contains(edge)) + + repository.deleteEdge(edge) + + val graph = repository.graph.first() + assertFalse(graph.edges.contains(edge)) + } + + @Test + fun setEdgeDisabled_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + repository.addEdge(node1, node2, "tip") + + val edge = repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + repository.setEdgeDisabled(edge, true) + + val graph = repository.graph.first() + val updatedEdge = graph.edges.find { it.fromNodeId == node1 && it.toNodeId == node2 } + assertTrue(updatedEdge?.isDisabled == true) + } + + @Test + fun updateNodeData_updatesGraph() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val newData = NodeData.Tip(ProtoBrushTip.newBuilder().addBehaviors(ProtoBrushBehavior.getDefaultInstance()).build()) + repository.updateNodeData(nodeId, newData) + + val graph = repository.graph.first() + val node = graph.nodes.find { it.id == nodeId }!! + assertEquals(newData, node.data) + } + + @Test + fun setNodeDisabled_updatesGraph() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + repository.setNodeDisabled(nodeId, true) + + val graph = repository.graph.first() + val node = graph.nodes.find { it.id == nodeId }!! + assertTrue(node.isDisabled) + } + + @Test + fun deleteSelectedNodes_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + + val initialCount = repository.graph.first().nodes.size + + repository.deleteSelectedNodes(setOf(node1, node2)) + + val graph = repository.graph.first() + assertEquals(initialCount - 2, graph.nodes.size) + assertFalse(graph.nodes.any { it.id == node1 || it.id == node2 }) + } + + @Test + fun duplicateSelectedNodes_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + + val initialCount = repository.graph.first().nodes.size + + val idMap = repository.duplicateSelectedNodes(setOf(node1)) + + val graph = repository.graph.first() + assertEquals(initialCount + 1, graph.nodes.size) + assertTrue(idMap.containsKey(node1)) + val duplicatedId = idMap[node1]!! + assertTrue(graph.nodes.any { it.id == duplicatedId }) + } + + @Test + fun addNodeBetween_updatesGraph() = testScope.runTest { + val behavior1 = NodeData.Behavior(ProtoBrushBehavior.Node.getDefaultInstance()) + val behavior2 = NodeData.Behavior(ProtoBrushBehavior.Node.getDefaultInstance(), inputPortIds = listOf("input1")) + val node1 = repository.addNode(behavior1) + val node2 = repository.addNode(behavior2) + repository.addEdge(node1, node2, "input1") + + val edge = repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + val newNodeId = repository.addNodeBetween(edge) + + assertNotNull(newNodeId) + val graph = repository.graph.first() + + val edge1 = graph.edges.find { it.fromNodeId == node1 && it.toNodeId == newNodeId } + val edge2 = graph.edges.find { it.fromNodeId == newNodeId && it.toNodeId == node2 } + assertNotNull(edge1) + assertNotNull(edge2) + assertFalse(graph.edges.contains(edge)) + } + + @Test + fun reorderPorts_updatesGraph() = testScope.runTest { + val familyNode = repository.graph.first().nodes.find { it.data is NodeData.Family }!! + + val coat1 = repository.addNode(NodeData.Coat()) + val coat2 = repository.addNode(NodeData.Coat()) + + repository.addEdge(coat1, familyNode.id, "add_coat") + repository.addEdge(coat2, familyNode.id, "add_coat") + + val updatedFamilyNode = repository.graph.first().nodes.find { it.id == familyNode.id }!! + val updatedData = updatedFamilyNode.data as NodeData.Family + val portIds = updatedData.coatPortIds + assertEquals(3, portIds.size) + + repository.reorderPorts(familyNode.id, 0, 1) + + val reorderedFamilyNode = repository.graph.first().nodes.find { it.id == familyNode.id }!! + val reorderedData = reorderedFamilyNode.data as NodeData.Family + assertEquals(portIds[1], reorderedData.coatPortIds[0]) + assertEquals(portIds[0], reorderedData.coatPortIds[1]) + } + +} + +/** A simple fake for CustomBrushDao for testing. */ +class FakeCustomBrushDao : CustomBrushDao { + private val brushes = mutableMapOf() + private val autoSaveFlow = MutableStateFlow(null) + + override fun getAllCustomBrushes(autosaveKey: String): Flow> { + return flowOf(brushes.values.filter { it.name != autosaveKey }) + } + + override fun getAutoSaveBrush(autosaveKey: String): Flow { + return autoSaveFlow.asStateFlow() + } + + override suspend fun saveCustomBrush(brush: CustomBrushEntity) { + brushes[brush.name] = brush + if (brush.name == "__autosave__") { + autoSaveFlow.value = brush + } + } + + override suspend fun deleteCustomBrush(name: String) { + brushes.remove(name) + if (name == "__autosave__") { + autoSaveFlow.value = null + } + } +} From 43e32b22fc7c4a82d103dc0c541437f8c86d7388 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 14:41:52 +0000 Subject: [PATCH 8/9] Remove file-wide opt-ins unneded on 1.1.0-alpha02 --- .../cahier/developer/brushgraph/data/BrushFamilyConverter.kt | 2 -- .../cahier/developer/brushgraph/data/BrushGraphConverter.kt | 2 -- .../cahier/developer/brushgraph/data/BrushGraphRepository.kt | 3 +-- .../developer/brushgraph/data/BrushGraphRepositoryTest.kt | 3 +-- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt index 77c4bff..b2f8aad 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt @@ -13,8 +13,6 @@ * * See the License for the specific language governing permissions and * * limitations under the License. */ -@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) - package com.example.cahier.developer.brushgraph.data import androidx.ink.brush.BrushFamily diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt index 0d8f1d2..151e819 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt @@ -13,8 +13,6 @@ * * See the License for the specific language governing permissions and * * limitations under the License. */ -@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) - package com.example.cahier.developer.brushgraph.data import androidx.ink.brush.BrushFamily diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt index b20b855..9066856 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -21,7 +21,6 @@ import com.example.cahier.R import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY -import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.BrushFamily import androidx.ink.storage.AndroidBrushFamilySerialization import androidx.ink.storage.BrushFamilyDecodeCallback @@ -77,7 +76,7 @@ interface BrushGraphRepository { } @Singleton -@OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) +@OptIn(FlowPreview::class) class DefaultBrushGraphRepository @Inject constructor( private val customBrushDao: CustomBrushDao, val textureStore: CahierTextureBitmapStore, diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt index 12b31f0..d7cccec 100644 --- a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt @@ -1,6 +1,5 @@ package com.example.cahier.developer.brushgraph.data -import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.BrushFamily import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity @@ -34,7 +33,7 @@ import ink.proto.BrushBehavior as ProtoBrushBehavior import ink.proto.BrushTip as ProtoBrushTip @RunWith(RobolectricTestRunner::class) -@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class BrushGraphRepositoryTest { private val testDispatcher = StandardTestDispatcher() From 00509d6ac8a4ce64ba23d3ed1590e8eb67eaaa4e Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 19:49:51 +0000 Subject: [PATCH 9/9] Refactor tests --- app/build.gradle.kts | 5 +++ .../viewmodel/DrawingCanvasViewModelTest.kt | 7 +--- .../com/example/cahier/core/di/AppModule.kt | 10 ++++++ .../data/BrushGraphRepositoryTest.kt | 27 +-------------- .../brushdesigner/data/FakeCustomBrushDao.kt | 33 +++++++++++++++++++ 5 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 132c61f..1c6c768 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,11 @@ android { isIncludeAndroidResources = true } } + + sourceSets { + getByName("test").kotlin.srcDir("src/testShared/java") + getByName("androidTest").kotlin.srcDir("src/testShared/java") + } } roborazzi { diff --git a/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt index ba5f3eb..a351713 100644 --- a/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt +++ b/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt @@ -37,6 +37,7 @@ import com.example.cahier.core.navigation.DrawingCanvasDestination import com.example.cahier.core.utils.FileHelper import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.Dispatchers @@ -218,9 +219,3 @@ class DrawingCanvasViewModelTest { ) } } - -private class FakeCustomBrushDao : CustomBrushDao { - override fun getAllCustomBrushes(): Flow> = flowOf(emptyList()) - override suspend fun saveCustomBrush(brush: CustomBrushEntity) {} - override suspend fun deleteCustomBrush(name: String) {} -} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/core/di/AppModule.kt b/app/src/main/java/com/example/cahier/core/di/AppModule.kt index a6b858b..fc36e02 100644 --- a/app/src/main/java/com/example/cahier/core/di/AppModule.kt +++ b/app/src/main/java/com/example/cahier/core/di/AppModule.kt @@ -28,6 +28,8 @@ import com.example.cahier.core.data.NotesRepository import com.example.cahier.core.data.OfflineNotesRepository import com.example.cahier.core.utils.FileHelper import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.DefaultBrushGraphRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -92,4 +94,12 @@ object AppModule { fun provideFileHelper(@ApplicationContext context: Context): FileHelper { return FileHelper(context) } + + @Provides + @Singleton + fun provideBrushGraphRepository( + impl: DefaultBrushGraphRepository + ): BrushGraphRepository { + return impl + } } \ No newline at end of file diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt index d7cccec..0b04344 100644 --- a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt @@ -3,6 +3,7 @@ package com.example.cahier.developer.brushgraph.data import androidx.ink.brush.BrushFamily import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao import com.example.cahier.core.ui.CahierTextureBitmapStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -308,30 +309,4 @@ class BrushGraphRepositoryTest { } -/** A simple fake for CustomBrushDao for testing. */ -class FakeCustomBrushDao : CustomBrushDao { - private val brushes = mutableMapOf() - private val autoSaveFlow = MutableStateFlow(null) - override fun getAllCustomBrushes(autosaveKey: String): Flow> { - return flowOf(brushes.values.filter { it.name != autosaveKey }) - } - - override fun getAutoSaveBrush(autosaveKey: String): Flow { - return autoSaveFlow.asStateFlow() - } - - override suspend fun saveCustomBrush(brush: CustomBrushEntity) { - brushes[brush.name] = brush - if (brush.name == "__autosave__") { - autoSaveFlow.value = brush - } - } - - override suspend fun deleteCustomBrush(name: String) { - brushes.remove(name) - if (name == "__autosave__") { - autoSaveFlow.value = null - } - } -} diff --git a/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt b/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt new file mode 100644 index 0000000..ae0b448 --- /dev/null +++ b/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt @@ -0,0 +1,33 @@ +package com.example.cahier.developer.brushdesigner.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOf + +class FakeCustomBrushDao : CustomBrushDao { + private val brushes = mutableMapOf() + private val autoSaveFlow = MutableStateFlow(null) + + override fun getAllCustomBrushes(autosaveKey: String): Flow> { + return flowOf(brushes.values.filter { it.name != autosaveKey }) + } + + override fun getAutoSaveBrush(autosaveKey: String): Flow { + return autoSaveFlow.asStateFlow() + } + + override suspend fun saveCustomBrush(brush: CustomBrushEntity) { + brushes[brush.name] = brush + if (brush.name == "__autosave__") { + autoSaveFlow.value = brush + } + } + + override suspend fun deleteCustomBrush(name: String) { + brushes.remove(name) + if (name == "__autosave__") { + autoSaveFlow.value = null + } + } +}