diff --git a/.gitignore b/.gitignore index 40301836..9898f10d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ .cxx local.properties ink-proto/bin/ +gradle/gradle-daemon-jvm.properties diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt new file mode 100644 index 00000000..3b0f0edb --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt @@ -0,0 +1,284 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.viewmodel.PrefabBehaviors +import ink.proto.BrushBehavior +import ink.proto.BrushFamily as ProtoBrushFamily + +/** + * Tab 2: Dynamics & Behaviors controls — editable behavior stack with nested + * node graph editor, standard dynamics presets, and advanced dynamics presets. + * + * Uses [EditableListWidget] for managing behaviors (outer list) and nodes + * within each behavior (inner nested list), with [NodeEditor] for + * type-specific node editing. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@Composable +internal fun BehaviorsTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdateBehaviors: (List) -> Unit, + onClearBehaviors: () -> Unit, + onAddBehavior: (List) -> Unit, +) { + val behaviors = activeProto + .coatsList.getOrNull(selectedCoatIndex)?.tip?.behaviorsList + ?: emptyList() + + Text( + stringResource(R.string.brush_designer_dynamics_behaviors), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_behavior_stack), + items = behaviors, + defaultItem = BrushBehavior.newBuilder() + .addNodes( + BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ) + ) + .addNodes( + BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f) + .setTargetModifierRangeEnd(1.5f) + ) + ) + .build(), + onItemsChanged = onUpdateBehaviors, + itemHeader = { behavior -> + val source = behavior.nodesList + .find { it.hasSourceNode() } + ?.sourceNode?.source?.name?.replace("SOURCE_", "") + ?: "INPUT" + val target = behavior.nodesList + .find { it.hasTargetNode() } + ?.targetNode?.target?.name?.replace("TARGET_", "") + ?: "OUTPUT" + "$source ➔ $target" + }, + editorContent = { behavior, onBehaviorChanged -> + BehaviorNodeGraphEditor( + behavior = behavior, + onBehaviorChanged = onBehaviorChanged + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + StandardDynamicsSection(onAddBehavior = onAddBehavior) + + Spacer(modifier = Modifier.height(16.dp)) + + AdvancedDynamicsSection( + onAddBehavior = onAddBehavior + ) +} + +/** + * Nested node graph editor for a single [BrushBehavior]. + * Uses a nested [EditableListWidget] to manage the ordered list of nodes. + */ +@Composable +private fun BehaviorNodeGraphEditor( + behavior: BrushBehavior, + onBehaviorChanged: (BrushBehavior) -> Unit +) { + Text( + stringResource(R.string.brush_designer_nodes_in_behavior), + style = MaterialTheme.typography.labelLarge + ) + + EditableListWidget( + title = "", + items = behavior.nodesList, + defaultItem = BrushBehavior.Node.newBuilder() + .setConstantNode( + BrushBehavior.ConstantNode.newBuilder().setValue(1f) + ) + .build(), + onItemsChanged = { newNodes -> + onBehaviorChanged( + behavior.toBuilder().clearNodes().addAllNodes(newNodes).build() + ) + }, + itemHeader = { node -> + node.nodeCase.name.replace("_NODE", "") + }, + editorContent = { node, onNodeChanged -> + NodeEditor(node = node, onNodeChanged = onNodeChanged) + } + ) +} + +@Composable +private fun StandardDynamicsSection( + onAddBehavior: (List) -> Unit +) { + Text( + stringResource(R.string.brush_designer_standard_dynamics), + style = MaterialTheme.typography.labelLarge + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f).setTargetModifierRangeEnd(1.5f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_pressure_size)) } + + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(50f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.2f).setTargetModifierRangeEnd(2.0f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_size)) } + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(5f).setSourceValueRangeEnd(40f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) + .setTargetModifierRangeStart(1.0f).setTargetModifierRangeEnd(0.1f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_opacity)) } + } +} + +@Composable +private fun AdvancedDynamicsSection( + onAddBehavior: (List) -> Unit +) { + Text( + stringResource(R.string.brush_designer_advanced_dynamics), + style = MaterialTheme.typography.labelLarge + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.pressureToSize()) } + ) { + Icon( + painterResource(R.drawable.brush_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_pressure_size)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.slantJitter()) } + ) { + Icon( + painterResource(R.drawable.texture_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_pencil_jitter)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.speedToOpacity()) } + ) { + Icon( + painterResource(R.drawable.opacity_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_speed_opacity)) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt index 0c6cce2f..f5a240ff 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt @@ -19,19 +19,42 @@ package com.example.cahier.developer.brushdesigner.ui import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold @@ -50,13 +73,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.cahier.R import com.example.cahier.developer.brushdesigner.viewmodel.BrushDesignerViewModel +import ink.proto.BrushTip as ProtoBrushTip /** * Main entry point for the Brush Designer feature. @@ -92,6 +118,7 @@ fun BrushDesignerScreen( val savedBrushes by viewModel.savedPaletteBrushes.collectAsStateWithLifecycle() val activeProto by viewModel.activeBrushProto.collectAsStateWithLifecycle() + val selectedCoatIndex by viewModel.selectedCoatIndex.collectAsStateWithLifecycle() val activeBrush by viewModel.activeBrush.collectAsStateWithLifecycle() val testStrokes by viewModel.testStrokes.collectAsStateWithLifecycle() val brushColor by viewModel.brushColor.collectAsStateWithLifecycle() @@ -145,8 +172,11 @@ fun BrushDesignerScreen( scaffoldState = bottomSheetState, sheetPeekHeight = 200.dp, sheetContent = { - ControlsPlaceholder( - modifier = Modifier.fillMaxWidth() + ControlsPane( + modifier = Modifier.fillMaxWidth(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel ) } ) { @@ -183,8 +213,11 @@ fun BrushDesignerScreen( ) }, listPane = { - ControlsPlaceholder( - modifier = Modifier.fillMaxSize() + ControlsPane( + modifier = Modifier.fillMaxSize(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel ) }, detailPane = { @@ -212,23 +245,391 @@ fun BrushDesignerScreen( } /** - * Placeholder for the controls pane — will be replaced with the full - * tabbed editor (Tip Shape / Paint / Behaviors) in a follow-up PR. + * The controls panel containing coat management, metadata fields, + * input model selector, and tabbed content (Tip Shape / Paint / Behaviors). + * + * Note: Still accepts ViewModel for delegation to tab content and input model + * sections. All state (activeProto, selectedCoatIndex) is hoisted from the screen. */ +@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalMaterial3Api::class) @Composable -private fun ControlsPlaceholder(modifier: Modifier = Modifier) { +private fun ControlsPane( + modifier: Modifier = Modifier, + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + viewModel: BrushDesignerViewModel +) { + var textFieldsLocked by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(BrushDesignerTab.TipShape) } + + val currentTip = activeProto + .coatsList.getOrNull(selectedCoatIndex)?.tip ?: ProtoBrushTip.getDefaultInstance() + val inputModel = activeProto.inputModel + + var showTextureDialog by remember { mutableStateOf(false) } + var pendingTextureUri by remember { mutableStateOf(null) } + var textureIdInput by remember { mutableStateOf("") } + + val texturePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + pendingTextureUri = it + showTextureDialog = true + } + } + + if (showTextureDialog) { + TextureNameDialog( + textureIdInput = textureIdInput, + onTextureIdChange = { textureIdInput = it }, + onConfirm = { + if (textureIdInput.isNotBlank() && pendingTextureUri != null) { + viewModel.addCustomTexture(pendingTextureUri!!, textureIdInput) + showTextureDialog = false + textureIdInput = "" + } + }, + onDismiss = { showTextureDialog = false } + ) + } + Column( - modifier = modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = stringResource(R.string.brush_designer_title), - style = MaterialTheme.typography.titleMedium + CoatLayersSection( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onSelectCoat = { viewModel.setSelectedCoat(it) }, + onAddCoat = { viewModel.addNewCoat() }, + onDeleteCoat = { viewModel.deleteSelectedCoat() } + ) + + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + MetadataSection( + clientId = activeProto.clientBrushFamilyId, + developerComment = activeProto.developerComment, + textFieldsLocked = textFieldsLocked, + onToggleLock = { textFieldsLocked = it }, + onClientIdChange = { viewModel.updateClientBrushFamilyId(it) }, + onCommentChange = { viewModel.updateDeveloperComment(it) } + ) + + HorizontalDivider() + + InputModelSection( + inputModel = inputModel, + onUpdateInputModelToPassthrough = { viewModel.updateInputModelToPassthrough() }, + onUpdateSlidingWindowModel = { ms, + hz -> + viewModel.updateSlidingWindowModel(ms, hz) + } + ) + + HorizontalDivider() + + TabRow(selectedTabIndex = selectedTab.ordinal) { + BrushDesignerTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { Text(stringResource(tab.labelResId)) } + ) + } + } + + when (selectedTab) { + BrushDesignerTab.TipShape -> TipShapeTabContent( + currentTip = currentTip, + activeBrush = viewModel.getActiveBrush(), + textureStore = viewModel.getTextureStore(), + onUpdateTip = { block -> viewModel.updateTip(block) } + ) + + BrushDesignerTab.Paint -> PaintTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdatePaintPreferences = { viewModel.updatePaintPreferences(it) }, + onUpdateSelfOverlap = { viewModel.updateSelfOverlap(it) }, + texturePickerLauncher = texturePickerLauncher, + getTextureBitmap = { viewModel.getTextureBitmap(it) } + ) + + BrushDesignerTab.Behaviors -> BehaviorsTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdateBehaviors = { viewModel.updateBehaviorsList(it) }, + onClearBehaviors = { viewModel.clearBehaviors() }, + onAddBehavior = { nodes -> viewModel.addBehavior(nodes) }, + ) + } + } +} + +@Composable +internal fun CoatLayersSection( + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + onSelectCoat: (Int) -> Unit, + onAddCoat: () -> Unit, + onDeleteCoat: () -> Unit +) { + Text( + stringResource(R.string.brush_designer_brush_layers), + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + activeProto.coatsList.forEachIndexed { index, _ -> + FilterChip( + selected = selectedCoatIndex == index, + onClick = { onSelectCoat(index) }, + label = { Text(stringResource(R.string.brush_designer_coat_label, index + 1)) } + ) + } + + IconButton(onClick = onAddCoat) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.brush_designer_add_layer) + ) + } + + if (activeProto.coatsList.size > 1) { + IconButton(onClick = onDeleteCoat) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.brush_designer_delete_layer), + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +internal fun MetadataSection( + clientId: String, + developerComment: String, + textFieldsLocked: Boolean, + onToggleLock: (Boolean) -> Unit, + onClientIdChange: (String) -> Unit, + onCommentChange: (String) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = textFieldsLocked, + onCheckedChange = onToggleLock ) Text( - text = stringResource(R.string.brush_designer_controls_placeholder), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + stringResource(R.string.brush_designer_lock_fields), + style = MaterialTheme.typography.bodyMedium ) } + + OutlinedTextField( + value = clientId, + onValueChange = onClientIdChange, + label = { Text(stringResource(R.string.brush_designer_client_id)) }, + enabled = !textFieldsLocked, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = developerComment, + onValueChange = onCommentChange, + label = { Text(stringResource(R.string.brush_designer_developer_comment)) }, + enabled = !textFieldsLocked, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputModelSection( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateInputModelToPassthrough: () -> Unit, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + Text( + stringResource(R.string.brush_designer_input_model), + style = MaterialTheme.typography.titleMedium + ) + + var expandedModelMenu by remember { mutableStateOf(false) } + val currentModelString = when { + inputModel.hasPassthroughModel() -> + stringResource(R.string.brush_designer_passthrough_model) + + inputModel.hasSlidingWindowModel() -> + stringResource(R.string.brush_designer_sliding_window) + + else -> stringResource(R.string.brush_designer_sliding_window_default) + } + + ExposedDropdownMenuBox( + expanded = expandedModelMenu, + onExpandedChange = { expandedModelMenu = it }, + ) { + OutlinedTextField( + value = currentModelString, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_model_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedModelMenu) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + DropdownMenu( + expanded = expandedModelMenu, + onDismissRequest = { expandedModelMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_passthrough_model)) }, + onClick = { + onUpdateInputModelToPassthrough() + expandedModelMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_sliding_window)) }, + onClick = { + onUpdateSlidingWindowModel(20L, 180) + expandedModelMenu = false + } + ) + } + } + + if (inputModel.hasSlidingWindowModel() || !inputModel.hasPassthroughModel()) { + SlidingWindowControls( + inputModel = inputModel, + onUpdateSlidingWindowModel = onUpdateSlidingWindowModel + ) + } +} + +@Composable +internal fun SlidingWindowControls( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + val swModel = inputModel.slidingWindowModel + val windowMs = + if (swModel.hasWindowSizeSeconds()) (swModel.windowSizeSeconds * 1000) + .toLong() else 20L + val upsamplingHz = if (swModel.hasExperimentalUpsamplingPeriodSeconds()) { + val period = swModel.experimentalUpsamplingPeriodSeconds + if (period == Float.POSITIVE_INFINITY || period == 0f) 0 else (1f / period).toInt() + } else 180 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_window_size_ms), + value = windowMs.toFloat(), + valueRange = 1f..100f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(newValue.toLong(), upsamplingHz) + } + ) + BrushSliderControl( + label = stringResource(R.string.brush_designer_upsampling_frequency_hz), + value = upsamplingHz.toFloat(), + valueRange = 0f..500f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(windowMs, newValue.toInt()) + } + ) + } + } +} + +@Composable +internal fun TextureNameDialog( + textureIdInput: String, + onTextureIdChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.brush_designer_name_texture_title)) }, + text = { + OutlinedTextField( + value = textureIdInput, + onValueChange = onTextureIdChange, + label = { Text(stringResource(R.string.brush_designer_texture_id_hint)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.brush_designer_load)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 200) +@Composable +private fun BrushSliderControlPreview() { + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_scale_x), + value = 1.5f, + valueRange = 0.1f..5f, + onValueChange = {} + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 300) +@Composable +private fun TipShapeTabPreview() { + MaterialTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TipShapeTabContent( + currentTip = ProtoBrushTip.getDefaultInstance(), + activeBrush = null, + textureStore = null, + onUpdateTip = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt new file mode 100644 index 00000000..b6878ed6 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt @@ -0,0 +1,224 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.cahier.R + +/** + * Wrapper for A/B testing: tracks whether each item is enabled or disabled + * without removing it from the list. + */ +data class CheckableItem(val item: T, val enabled: Boolean = true) + +/** + * A generic list editor that supports add, delete, duplicate, and A/B + * testing (enable/disable toggle) for ordered lists of proto items. + * + * Stateless: the canonical item list is passed in via [items], and all + * changes are emitted via [onItemsChanged] with only the enabled items. + * + * @param title section header text + * @param items current list of items (from proto) + * @param defaultItem factory default for new items + * @param onItemsChanged callback with the updated full list of items + * @param itemHeader display label for each item in collapsed view + * @param editorContent expanded editor for the selected item + */ +@Composable +internal fun EditableListWidget( + title: String, + items: List, + defaultItem: T, + onItemsChanged: (List) -> Unit, + itemHeader: @Composable (T) -> String, + editorContent: @Composable (item: T, onItemChanged: (T) -> Unit) -> Unit +) { + var itemStates by remember { mutableStateOf(items + .map { CheckableItem(it, true) }) } + + + if (itemStates.size != items.size || + itemStates.zip(items).any { (s, i) -> s.item != i }) { + itemStates = items.mapIndexed { index, item -> + CheckableItem(item, itemStates + .getOrNull(index)?.enabled ?: true) + } + } + var selectedIndex by remember { mutableStateOf(null) } + + fun emitEnabledItems() { + onItemsChanged(itemStates.filter { it.enabled }.map { it.item }) + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (title.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + IconButton(onClick = { + itemStates = itemStates + CheckableItem(defaultItem, true) + selectedIndex = itemStates.size + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource( + R.string.brush_designer_add_item, title + ), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + itemStates.forEachIndexed { index, state -> + val isSelected = selectedIndex == index + Card( + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + onClick = { selectedIndex = if (isSelected) null else index } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = state.enabled, + onCheckedChange = { checked -> + val newList = itemStates.toMutableList() + newList[index] = state.copy(enabled = checked) + itemStates = newList + emitEnabledItems() + } + ) + + Text( + text = itemHeader(state.item), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.add(index + 1, state.copy()) + itemStates = newList + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.content_copy_24px), + contentDescription = stringResource( + R.string.brush_designer_duplicate_item + ) + ) + } + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.removeAt(index) + itemStates = newList + if (selectedIndex == index) selectedIndex = null + else if (selectedIndex != null && selectedIndex!! > index) { + selectedIndex = selectedIndex!! - 1 + } + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource( + R.string.brush_designer_delete_item + ), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + selectedIndex?.let { index -> + if (index < itemStates.size) { + val currentItem = itemStates[index].item + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + border = BorderStroke( + 1.dp, MaterialTheme.colorScheme.outlineVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource( + R.string.brush_designer_editing_item, index + 1 + ), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge + ) + Spacer(Modifier.height(8.dp)) + + editorContent(currentItem) { updatedItem -> + val newList = itemStates.toMutableList() + newList[index] = itemStates[index].copy(item = updatedItem) + itemStates = newList + emitEnabledItems() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt new file mode 100644 index 00000000..be135437 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt @@ -0,0 +1,158 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import ink.proto.BrushBehavior + +/** + * A Canvas-based composable that visually plots the easing/response function + * of a [BrushBehavior.ResponseNode]. + * + * Supports: + * - Cubic Bézier curves (cubicBezierResponseCurve) + * - Linear piecewise curves (linearResponseCurve) + * - Predefined easing functions (rendered as a straight line placeholder) + * - Steps response curves (rendered as a staircase) + */ +@Composable +internal fun MathCurvePreview( + responseNode: BrushBehavior.ResponseNode, + modifier: Modifier = Modifier +) { + val lineColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .height(150.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + val w = size.width + val h = size.height + + drawLine( + gridColor, + start = Offset(0f, h), + end = Offset(w, h), + strokeWidth = 2f + ) + drawLine( + gridColor, + start = Offset(0f, 0f), + end = Offset(0f, h), + strokeWidth = 2f + ) + + for (i in 1..3) { + val frac = i / 4f + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(0f, h * (1f - frac)), + end = Offset(w, h * (1f - frac)), + strokeWidth = 1f + ) + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(w * frac, 0f), + end = Offset(w * frac, h), + strokeWidth = 1f + ) + } + + val path = Path() + val curveCase = responseNode.responseCurveCase + + when (curveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = responseNode.cubicBezierResponseCurve + path.moveTo(0f, h) + path.cubicTo( + cb.x1 * w, h - (cb.y1 * h), + cb.x2 * w, h - (cb.y2 * h), + w, 0f + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> { + val linear = responseNode.linearResponseCurve + val xs = linear.xList + val ys = linear.yList + if (xs.isNotEmpty() && ys.isNotEmpty()) { + path.moveTo(xs[0] * w, h - (ys[0] * h)) + for (i in 1 until minOf(xs.size, ys.size)) { + path.lineTo(xs[i] * w, h - (ys[i] * h)) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = responseNode.stepsResponseCurve + val stepCount = steps.stepCount + if (stepCount > 0) { + val stepWidth = w / stepCount + val stepHeight = h / stepCount + path.moveTo(0f, h) + for (i in 0 until stepCount) { + val y = h - ((i + 1) * stepHeight) + path.lineTo(i * stepWidth, y) + path.lineTo((i + 1) * stepWidth, y) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + else -> { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + drawPath( + path, + color = lineColor, + style = Stroke(width = 4f) + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt new file mode 100644 index 00000000..3a52d548 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt @@ -0,0 +1,535 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushBehavior + +/** + * Dispatches to the correct editor based on the [BrushBehavior.Node.NodeCase]. + * + * Each editor is stateless: it receives the current node and emits + * the updated node via [onNodeChanged]. + */ +@Composable +internal fun NodeEditor( + node: BrushBehavior.Node, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + when (node.nodeCase) { + BrushBehavior.Node.NodeCase.SOURCE_NODE -> + SourceNodeEditor(node.sourceNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.RESPONSE_NODE -> + ResponseNodeEditor(node.responseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.DAMPING_NODE -> + DampingNodeEditor(node.dampingNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.NOISE_NODE -> + NoiseNodeEditor(node.noiseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.TARGET_NODE -> + TargetNodeEditor(node.targetNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.CONSTANT_NODE -> + ConstantNodeEditor(node.constantNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> + BinaryOpNodeEditor(node.binaryOpNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> + InterpolationNodeEditor(node.interpolationNode, onNodeChanged) + + else -> Text( + "Unsupported node type: ${node.nodeCase}", + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +internal fun SourceNodeEditor( + source: BrushBehavior.SourceNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_source), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + val sources = BrushBehavior.Source.entries.filter { + it != BrushBehavior.Source.SOURCE_UNSPECIFIED + } + EnumDropdown( + label = stringResource(R.string.brush_designer_node_source_input), + currentValue = source.source, + values = sources, + displayName = { it.name.replace("SOURCE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_start), + value = source.sourceValueRangeStart, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_end), + value = source.sourceValueRangeEnd, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeEnd(it) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_out_of_range), + currentValue = source.sourceOutOfRangeBehavior, + values = BrushBehavior.OutOfRange.entries.filter { + it != BrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED + }, + displayName = { it.name.replace("OUT_OF_RANGE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceOutOfRangeBehavior(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ResponseNodeEditor( + response: BrushBehavior.ResponseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_response_curve), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + MathCurvePreview( + responseNode = response, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val currentType = when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> + ResponseCurveType.CubicBezier + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> + ResponseCurveType.Linear + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> + ResponseCurveType.Steps + else -> ResponseCurveType.Predefined + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_curve_type), + currentValue = currentType, + values = ResponseCurveType.entries.toList(), + displayName = { it.displayName }, + onSelected = { selected -> + val newResponseBuilder = response.toBuilder() + when (selected) { + ResponseCurveType.Predefined -> + newResponseBuilder.setPredefinedResponseCurve( + ink.proto.PredefinedEasingFunction.PREDEFINED_EASING_EASE + ) + ResponseCurveType.CubicBezier -> + newResponseBuilder.setCubicBezierResponseCurve( + ink.proto.CubicBezierEasingFunction.newBuilder() + .setX1(0.25f).setY1(0.1f).setX2(0.25f).setY2(1f) + ) + ResponseCurveType.Linear -> + newResponseBuilder.setLinearResponseCurve( + ink.proto.LinearEasingFunction.newBuilder() + ) + ResponseCurveType.Steps -> + newResponseBuilder.setStepsResponseCurve( + ink.proto.StepsEasingFunction.newBuilder().setStepCount(4) + ) + } + onNodeChanged( + BrushBehavior.Node.newBuilder() + .setResponseNode(newResponseBuilder).build() + ) + } + ) + + when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = response.cubicBezierResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_control_x1), + value = cb.x1, + limits = NumericLimits(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y1), + value = cb.y1, + limits = NumericLimits(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_x2), + value = cb.x2, + limits = NumericLimits(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX2(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y2), + value = cb.y2, + limits = NumericLimits(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY2(it) + ) + ).build() + ) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> { + EnumDropdown( + label = stringResource(R.string.brush_designer_node_predefined_curve), + currentValue = response.predefinedResponseCurve, + values = ink.proto.PredefinedEasingFunction.entries.toList(), + displayName = { + it.name.replace("PREDEFINED_EASING_FUNCTION_", "") + }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setPredefinedResponseCurve(selected) + ).build() + ) + } + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = response.stepsResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_step_count), + value = steps.stepCount.toFloat(), + limits = NumericLimits(1f, 20f, 1f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setStepsResponseCurve( + steps.toBuilder().setStepCount(it.toInt()) + ) + ).build() + ) + } + } + + else -> { + Text( + "Linear curves: edit points via export/import.", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +internal fun DampingNodeEditor( + damping: BrushBehavior.DampingNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_damping), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_damping_source), + currentValue = damping.dampingSource, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_gap_seconds), + value = damping.dampingGap, + limits = NumericLimits(0.01f, 2f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingGap(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun NoiseNodeEditor( + noise: BrushBehavior.NoiseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text( + text = stringResource(R.string.brush_designer_node_noise), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_seed), + value = noise.seed.toFloat(), + limits = NumericLimits(-10000f, 10000f, 1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setSeed(it.toInt()) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_vary_over), + currentValue = noise.varyOver, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setVaryOver(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_base_period), + value = noise.basePeriod, + limits = NumericLimits(0.01f, 5f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setBasePeriod(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun TargetNodeEditor( + target: BrushBehavior.TargetNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_target), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_target_output), + currentValue = target.target, + values = BrushBehavior.Target.entries.filter { + it != BrushBehavior.Target.TARGET_UNSPECIFIED + }, + displayName = { it.name.replace("TARGET_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTarget(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_start), + value = target.targetModifierRangeStart, + limits = NumericLimits(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_end), + value = target.targetModifierRangeEnd, + limits = NumericLimits(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeEnd(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ConstantNodeEditor( + constant: BrushBehavior.ConstantNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_constant_value), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + NumericField( + title = stringResource(R.string.brush_designer_node_value), + value = constant.value, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setConstantNode( + constant.toBuilder().setValue(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun BinaryOpNodeEditor( + binaryOp: BrushBehavior.BinaryOpNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_binary_operation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_operation), + currentValue = binaryOp.operation, + values = BrushBehavior.BinaryOp.entries.filter { + it != BrushBehavior.BinaryOp.BINARY_OP_UNSPECIFIED + }, + displayName = { it.name.replace("BINARY_OP_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setBinaryOpNode( + binaryOp.toBuilder().setOperation(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun InterpolationNodeEditor( + interpolation: BrushBehavior.InterpolationNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_interpolation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_interpolation), + currentValue = interpolation.interpolation, + values = BrushBehavior.Interpolation.entries.filter { + it != BrushBehavior.Interpolation.INTERPOLATION_UNSPECIFIED + }, + displayName = { it.name.replace("INTERPOLATION_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setInterpolationNode( + interpolation.toBuilder().setInterpolation(selected) + ).build() + ) + } + ) + } +} + +/** Response curve types available in the curve type selector dropdown. */ +private enum class ResponseCurveType(val displayName: String) { + Predefined("Predefined"), + CubicBezier("Cubic Bézier"), + Linear("Linear"), + Steps("Steps"); +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt new file mode 100644 index 00000000..1265bf2a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt @@ -0,0 +1,604 @@ +/* + * 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.brushdesigner.ui + +import android.graphics.Bitmap +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.ColorFunction as ProtoColorFunction + +/** + * Tab 1: Paint & texture controls — multi-layer textures, multi-function colors, + * self overlap, and texture import. + * + * Uses [EditableListWidget] for managing multiple texture layers and color + * functions with add/remove/duplicate/toggle support. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PaintTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdatePaintPreferences: (List) -> Unit, + onUpdateSelfOverlap: (ProtoBrushPaint.SelfOverlap) -> Unit, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest>, + getTextureBitmap: (String) -> Bitmap? +) { + val paintPrefs = + activeProto.coatsList.getOrNull(selectedCoatIndex)?.paintPreferencesList + ?: emptyList() + + val availableTextures = activeProto.textureIdToBitmapMap.keys.toList() + + Text( + text = stringResource(R.string.brush_designer_paint_texture), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_paint_preferences), + items = paintPrefs, + defaultItem = ProtoBrushPaint.newBuilder() + .addTextureLayers( + ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + ) + .build(), + onItemsChanged = onUpdatePaintPreferences, + itemHeader = { paint -> + val texCount = paint.textureLayersCount + val colorCount = paint.colorFunctionsCount + "$texCount texture(s), $colorCount color fn(s)" + }, + editorContent = { paint, onPaintChanged -> + PaintPreferenceEditor( + paint = paint, + availableTextures = availableTextures, + onPaintChanged = onPaintChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SelfOverlapSelector( + currentPaint = paintPrefs.firstOrNull() ?: ProtoBrushPaint.getDefaultInstance(), + onOverlapSelected = onUpdateSelfOverlap + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + + TextureImportSection( + textureCount = activeProto.textureIdToBitmapMap.size, + texturePickerLauncher = texturePickerLauncher + ) +} + +/** + * Editor for a single paint preference, containing its own texture layers + * and color functions via nested [EditableListWidget]. + */ +@Composable +private fun PaintPreferenceEditor( + paint: ProtoBrushPaint, + availableTextures: List, + onPaintChanged: (ProtoBrushPaint) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + EditableListWidget( + title = stringResource(R.string.brush_designer_texture_layers), + items = paint.textureLayersList, + defaultItem = ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + .build(), + onItemsChanged = { newLayers -> + onPaintChanged( + paint.toBuilder().clearTextureLayers() + .also { b -> newLayers.forEach { b.addTextureLayers(it) } } + .build() + ) + }, + itemHeader = { layer -> + val texId = + layer.clientTextureId.ifEmpty { stringResource(R.string.brush_designer_empty) } + val blend = layer.blendMode.name.replace("BLEND_MODE_", "") + "$texId ($blend)" + }, + editorContent = { layer, onLayerChanged -> + TextureLayerEditor( + layer = layer, + availableTextures = availableTextures, + onLayerChanged = onLayerChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + if (paint.colorFunctionsList.isEmpty()) { + Text( + text = stringResource(R.string.brush_designer_no_color_functions), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + EditableListWidget( + title = stringResource(R.string.brush_designer_color_functions), + items = paint.colorFunctionsList, + defaultItem = ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build(), + onItemsChanged = { newFuncs -> + onPaintChanged( + paint.toBuilder().clearColorFunctions() + .also { b -> newFuncs.forEach { b.addColorFunctions(it) } } + .build() + ) + }, + itemHeader = { func -> + when (func.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> + stringResource( + R.string.brush_designer_opacity_multiplier, + func.opacityMultiplier + ) + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> + stringResource(R.string.brush_designer_replace_color) + + else -> stringResource(R.string.brush_designer_unknown) + } + }, + editorContent = { func, onFuncChanged -> + ColorFunctionEditor( + colorFunction = func, + onFunctionChanged = onFuncChanged + ) + } + ) +} + +/** + * Full editor for a single [ProtoBrushPaint.TextureLayer], including all + * fields from SSA: texture ID, mapping, size unit, scale, rotation, + * origin, offset, wrap, and blend mode. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureLayerEditor( + layer: ProtoBrushPaint.TextureLayer, + availableTextures: List, + onLayerChanged: (ProtoBrushPaint.TextureLayer) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + val bitmap = if (layer.clientTextureId.isNotEmpty()) { + getTextureBitmap(layer.clientTextureId) + } else null + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource( + id = R.string.brush_designer_texture_content_desc, + layer.clientTextureId + ), + modifier = Modifier + .size(80.dp) + .padding(bottom = 8.dp) + ) + } + + TextureIdSelector( + currentId = layer.clientTextureId, + availableTextures = availableTextures, + onTextureSelected = { id -> + onLayerChanged(layer.toBuilder().setClientTextureId(id).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_mapping_mode), + currentValue = layer.mapping, + values = ProtoBrushPaint.TextureLayer.Mapping.entries.filter { + it != ProtoBrushPaint.TextureLayer.Mapping.MAPPING_UNSPECIFIED + }, + displayName = { it.name.replace("MAPPING_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setMapping(it).build()) } + ) + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) { + EnumDropdown( + label = stringResource(R.string.brush_designer_size_unit), + currentValue = layer.sizeUnit, + values = ProtoBrushPaint.TextureLayer.SizeUnit.entries.filter { + it != ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_UNSPECIFIED + }, + displayName = { it.name.replace("SIZE_UNIT_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setSizeUnit(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_scale_x), + value = if (layer.hasSizeX()) layer.sizeX else 1f, + limits = NumericLimits(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_scale_y), + value = if (layer.hasSizeY()) layer.sizeY else 1f, + limits = NumericLimits(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeY(it).build()) } + ) + } + + NumericField( + title = stringResource(R.string.brush_designer_rotation), + value = if (layer.hasRotationInRadians()) layer.rotationInRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { + onLayerChanged(layer.toBuilder().setRotationInRadians(it).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_origin), + currentValue = layer.origin, + values = ProtoBrushPaint.TextureLayer.Origin.entries.toList(), + displayName = { it.name.replace("ORIGIN_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setOrigin(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_offset_x), + value = if (layer.hasOffsetX()) layer.offsetX else 0f, + limits = NumericLimits(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_offset_y), + value = if (layer.hasOffsetY()) layer.offsetY else 0f, + limits = NumericLimits(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_x), + currentValue = layer.wrapX, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapX(it).build()) } + ) + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_y), + currentValue = layer.wrapY, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_blend_mode), + currentValue = layer.blendMode, + values = ProtoBrushPaint.TextureLayer.BlendMode.entries.filter { + it != ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_UNSPECIFIED + }, + displayName = { it.name.replace("BLEND_MODE_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setBlendMode(it).build()) } + ) + Text( + text = blendModeDescription(layer.blendMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) +} + +/** + * Editor for a single [ProtoColorFunction], supporting OpacityMultiplier + * and ReplaceColor function types. + */ +@Composable +private fun ColorFunctionEditor( + colorFunction: ProtoColorFunction, + onFunctionChanged: (ProtoColorFunction) -> Unit +) { + val functionTypes = listOf("Opacity Multiplier", "Replace Color") + val currentType = when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> 1 + else -> 0 + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_function_type), + currentValue = functionTypes[currentType], + values = functionTypes, + displayName = { it }, + onSelected = { selected -> + when (functionTypes.indexOf(selected)) { + 0 -> onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() + ) + + 1 -> onFunctionChanged( + ProtoColorFunction.newBuilder() + .setReplaceColor( + ink.proto.Color.getDefaultInstance() + ) + .build() + ) + } + } + ) + + when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> { + NumericField( + title = stringResource(R.string.brush_designer_opacity_multiplier_label), + value = colorFunction.opacityMultiplier, + limits = NumericLimits(0f, 2f, 0.05f), + onValueChanged = { + onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(it).build() + ) + } + ) + } + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> { + Text( + text = stringResource(R.string.brush_designer_replace_color_message), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + else -> { + Text( + text = stringResource(R.string.brush_designer_unknown_color_function), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +/** + * A generic [ExposedDropdownMenuBox] for selecting from enum-like value lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EnumDropdown( + label: String, + currentValue: T, + values: List, + displayName: (T) -> String, + onSelected: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = displayName(currentValue), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + values.forEach { value -> + DropdownMenuItem( + text = { Text(displayName(value)) }, + onClick = { + onSelected(value) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureIdSelector( + currentId: String, + availableTextures: List, + onTextureSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = currentId.ifEmpty { stringResource(R.string.brush_designer_no_texture) }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_texture_id)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availableTextures.forEach { id -> + DropdownMenuItem( + text = { Text(id) }, + onClick = { + onTextureSelected(id) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelfOverlapSelector( + currentPaint: ProtoBrushPaint, + onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit +) { + var overlapExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = overlapExpanded, + onExpandedChange = { overlapExpanded = it } + ) { + OutlinedTextField( + value = currentPaint.selfOverlap.name.replace("_", " "), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_self_overlap)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = overlapExpanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = overlapExpanded, + onDismissRequest = { overlapExpanded = false } + ) { + ProtoBrushPaint.SelfOverlap.entries + .filter { it != ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_UNSPECIFIED } + .forEach { overlap -> + DropdownMenuItem( + text = { Text(overlap.name) }, + onClick = { + onOverlapSelected(overlap) + overlapExpanded = false + } + ) + } + } + } +} + +@Composable +private fun TextureImportSection( + textureCount: Int, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest> +) { + Text( + text = stringResource(R.string.brush_designer_textures), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + Button( + onClick = { + texturePickerLauncher.launch( + androidx.activity.result.PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.brush_designer_import_texture)) + } + + Text( + stringResource(R.string.brush_designer_loaded_textures, textureCount), + style = MaterialTheme.typography.bodySmall + ) +} + +/** Returns a short human-readable description for each blend mode. */ +private fun blendModeDescription(mode: ProtoBrushPaint.TextureLayer.BlendMode): String = + when (mode) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> + "Default — texture drawn over destination." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> + "Keeps destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> + "Cuts out destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> + "Draws texture only where destination exists." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> + "Draws texture only where destination is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> + "Multiplies source and destination colors." + + else -> "" + } diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt new file mode 100644 index 00000000..8b460ae9 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt @@ -0,0 +1,169 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import com.example.cahier.R +import com.example.cahier.core.ui.CahierTextureBitmapStore +import ink.proto.BrushTip as ProtoBrushTip + +/** + * Tab 0: Tip geometry controls — scale (with lock toggle), corner rounding, + * slant, rotation, pinch, and particle (stamp) settings. + * + * Uses [NumericField] for professional ±button input with degree/percent + * unit conversions and click-to-edit exact-value entry. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalInkCustomBrushApi::class) +@Composable +internal fun TipShapeTabContent( + currentTip: ProtoBrushTip, + activeBrush: Brush?, + textureStore: CahierTextureBitmapStore?, + onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit +) { + var isScaleLocked by remember { mutableStateOf(false) } + + TipPreview( + brush = activeBrush, + textureStore = textureStore, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.brush_designer_tip_geometry), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.brush_designer_lock_scale_ratio), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = isScaleLocked, onCheckedChange = { isScaleLocked = it }) + } + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_x), + value = if (currentTip.hasScaleX()) currentTip.scaleX else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newX -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleX(newX) + if (isScaleLocked && oldX > 0f) { + setScaleY(oldY * (newX / oldX)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_y), + value = if (currentTip.hasScaleY()) currentTip.scaleY else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newY -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleY(newY) + if (isScaleLocked && oldY > 0f) { + setScaleX(oldX * (newY / oldY)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_corner_rounding), + value = if (currentTip.hasCornerRounding()) currentTip.cornerRounding else 1f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setCornerRounding(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_slant), + value = if (currentTip.hasSlantRadians()) currentTip.slantRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), + onValueChanged = { newValue -> onUpdateTip { setSlantRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_rotation), + value = if (currentTip.hasRotationRadians()) currentTip.rotationRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { newValue -> onUpdateTip { setRotationRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_pinch), + value = if (currentTip.hasPinch()) currentTip.pinch else 0f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setPinch(newValue) } } + ) + + HorizontalDivider() + Text( + stringResource(R.string.brush_designer_particle_settings), + style = MaterialTheme.typography.titleSmall + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_distance_scale), + value = if (currentTip.hasParticleGapDistanceScale()) currentTip + .particleGapDistanceScale else 0f, + limits = NumericLimits(0f, 5f, 0.1f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDistanceScale(newValue) } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_duration_ms), + value = if (currentTip.hasParticleGapDurationSeconds()) currentTip + .particleGapDurationSeconds * 1000f else 0f, + limits = NumericLimits(0f, 250f, 5f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDurationSeconds(newValue / 1000f) } + } + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt new file mode 100644 index 00000000..0d3cbe98 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt @@ -0,0 +1,21 @@ +/* + * * 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 + +sealed class DisplayText { + data class Resource(val resId: Int, val args: List = emptyList()) : DisplayText() + data class Literal(val text: String) : DisplayText() +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt new file mode 100644 index 00000000..2fb5ef55 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt @@ -0,0 +1,365 @@ +/* + * * 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 androidx.ink.brush.InputToolType +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushFamily as ProtoBrushFamily +import com.example.cahier.R +import ink.proto.StepPosition as ProtoStepPosition +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import com.example.cahier.developer.brushdesigner.ui.NumericLimits + +fun ProtoBrushBehavior.Source.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> R.string.bg_source_normalized_pressure + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> R.string.bg_source_tilt + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS -> R.string.bg_source_tilt_x + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> R.string.bg_source_tilt_y + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> R.string.bg_source_orientation + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_orientation_about_zero + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_speed + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_x + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_y + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS -> R.string.bg_source_direction + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_direction_about_zero + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X -> R.string.bg_source_normalized_direction_x + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> R.string.bg_source_normalized_direction_y + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_traveled + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS -> R.string.bg_source_time_of_input_s + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_source_predicted_distance_traveled + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> R.string.bg_source_predicted_time_elapsed_s + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_remaining + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS -> R.string.bg_source_time_since_input_s + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS -> R.string.bg_source_time_since_stroke_end + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_x + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_y + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_forward + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_lateral + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_speed_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_x_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_y_absolute + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_source_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> + R.string.bg_source_predicted_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_source_acceleration_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_x_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_y_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_forward_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_lateral_absolute + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> + R.string.bg_source_distance_remaining_fraction + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.Source.getNumericLimits(): NumericLimits { + return when (this) { + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> NumericLimits(0f, 1f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(0f, 90f) + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-90f, 90f) + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(0f, 360f) + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-180f, 180f) + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> NumericLimits(0f, 1000f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> NumericLimits(-1000f, 1000f, 0.01f, "/s") + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X, + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> NumericLimits(-1f, 1f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 100f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 100f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s") + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> NumericLimits(0f, 100000f, 1f, "/s²") + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> NumericLimits(-100000f, 100000f, 1f, "/s²") + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> NumericLimits(0f, 100f, 0.1f, "cm/s") + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> NumericLimits(-100f, 100f, 0.1f, "cm/s") + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 100f, 0.01f, "cm") + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.01f, "cm") + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits(0f, 5000f, 0.1f, "cm/s²") + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits(-5000f, 5000f, 0.1f, "cm/s²") + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> NumericLimits.floatShownAsPercent(0f, 100f) + else -> NumericLimits(-100f, 100f, 0.01f) + } +} + +fun ProtoBrushBehavior.Target.getNumericLimits(): NumericLimits { + return when (this) { + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS, + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-360f, 360f) + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-90f, 90f) + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET, + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET, + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> NumericLimits.floatShownAsPercent(-100f, 100f) + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> NumericLimits(0f, 2f, 0.01f, "x") + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(-10.0f, 10.0f, 0.01f) + else -> NumericLimits(-100f, 100f, 0.01f) + } +} + +fun ProtoBrushBehavior.PolarTarget.getMagnitudeLimits(): NumericLimits { + return when (this) { + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> + NumericLimits(-10.0f, 10.0f, 0.01f) + else -> NumericLimits(0.0f, 1.0f, 0.1f) + } +} + +enum class ProgressDomainContext { + DAMPING, + INTEGRAL, + NOISE +} + +fun ProtoBrushBehavior.ProgressDomain.getNumericLimits(context: ProgressDomainContext): NumericLimits { + return when (context) { + ProgressDomainContext.DAMPING -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 100f, 0.1f, "mm") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f) + else -> NumericLimits(0f, 100f, 1f) + } + ProgressDomainContext.INTEGRAL -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s ⋅ input") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.01f, "cm ⋅ input") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f, "⋅ input") + else -> NumericLimits(0f, 100f, 1f) + } + ProgressDomainContext.NOISE -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.01f, "s") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.1f, "cm") + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f) + else -> NumericLimits(0f, 100f, 1f) + } + } +} + +fun ProtoBrushBehavior.Target.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER -> R.string.bg_target_width_multiplier + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER -> R.string.bg_target_height_multiplier + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER -> R.string.bg_target_size_multiplier + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> R.string.bg_target_slant_offset + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET -> R.string.bg_target_pinch_offset + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS -> R.string.bg_target_rotation_offset + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET -> R.string.bg_target_corner_rounding_offset + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_x + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_y + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_target_position_offset_forward + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_target_position_offset_lateral + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> R.string.bg_target_hue_offset + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER -> R.string.bg_target_saturation_multiplier + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> R.string.bg_target_luminosity_offset + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> R.string.bg_target_opacity_multiplier + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.PolarTarget.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_polar_target_position_offset_absolute + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_polar_target_position_offset_relative + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.BinaryOp.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.BinaryOp.BINARY_OP_PRODUCT -> R.string.bg_binary_op_product + ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM -> R.string.bg_binary_op_sum + ProtoBrushBehavior.BinaryOp.BINARY_OP_MIN -> R.string.bg_binary_op_min + ProtoBrushBehavior.BinaryOp.BINARY_OP_MAX -> R.string.bg_binary_op_max + ProtoBrushBehavior.BinaryOp.BINARY_OP_AND_THEN -> R.string.bg_binary_op_and_then + ProtoBrushBehavior.BinaryOp.BINARY_OP_OR_ELSE -> R.string.bg_binary_op_or_else + ProtoBrushBehavior.BinaryOp.BINARY_OP_XOR_ELSE -> R.string.bg_binary_op_xor_else + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.OutOfRange.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP -> R.string.bg_out_of_range_clamp + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_REPEAT -> R.string.bg_out_of_range_repeat + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_MIRROR -> R.string.bg_out_of_range_mirror + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.ProgressDomain.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> R.string.bg_progress_domain_distance_absolute + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_progress_domain_distance_size_relative + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> R.string.bg_progress_domain_time_seconds + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.Interpolation.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.Interpolation.INTERPOLATION_LERP -> R.string.bg_interpolation_lerp + ProtoBrushBehavior.Interpolation.INTERPOLATION_INVERSE_LERP -> R.string.bg_interpolation_inverse_lerp + else -> R.string.bg_node_unknown + } + +fun ProtoStepPosition.displayStringRId(): Int = + when (this) { + ProtoStepPosition.STEP_POSITION_JUMP_START -> R.string.bg_step_position_jump_start + ProtoStepPosition.STEP_POSITION_JUMP_END -> R.string.bg_step_position_jump_end + ProtoStepPosition.STEP_POSITION_JUMP_BOTH -> R.string.bg_step_position_jump_both + ProtoStepPosition.STEP_POSITION_JUMP_NONE -> R.string.bg_step_position_jump_none + else -> R.string.bg_node_unknown + } + +fun ProtoPredefinedEasingFunction.displayStringRId(): Int = + when (this) { + ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR -> R.string.bg_easing_linear + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE -> R.string.bg_easing_ease + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN -> R.string.bg_easing_ease_in + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_OUT -> R.string.bg_easing_ease_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN_OUT -> R.string.bg_easing_ease_in_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_START -> R.string.bg_easing_step_start + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_END -> R.string.bg_easing_step_end + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.ResponseNode.ResponseCurveCase.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> R.string.bg_tab_predefined + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> R.string.bg_tab_cubic_bezier + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> R.string.bg_tab_linear + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> R.string.bg_tab_steps + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.ResponseNode.displayStringRId(): Int = + this.responseCurveCase.displayStringRId() + +fun InputToolType.displayStringRId(): Int = + when (this) { + InputToolType.UNKNOWN -> R.string.bg_tool_type_unknown + InputToolType.MOUSE -> R.string.bg_tool_type_mouse + InputToolType.TOUCH -> R.string.bg_tool_type_touch + InputToolType.STYLUS -> R.string.bg_tool_type_stylus + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.SelfOverlap.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY -> R.string.bg_self_overlap_any + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE -> R.string.bg_self_overlap_accumulate + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD -> R.string.bg_self_overlap_discard + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.TextureLayer.SizeUnit.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE -> R.string.bg_size_unit_brush_size + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_STROKE_COORDINATES -> R.string.bg_size_unit_stroke_coordinates + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.TextureLayer.Origin.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_STROKE_SPACE_ORIGIN -> R.string.bg_origin_stroke_space_origin + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_FIRST_STROKE_INPUT -> R.string.bg_origin_first_stroke_input + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_LAST_STROKE_INPUT -> R.string.bg_origin_last_stroke_input + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.TextureLayer.Mapping.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING -> R.string.bg_mapping_tiling + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING -> R.string.bg_mapping_stamping + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.TextureLayer.Wrap.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT -> R.string.bg_wrap_repeat + ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR -> R.string.bg_wrap_mirror + ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP -> R.string.bg_wrap_clamp + else -> R.string.bg_node_unknown + } + +fun ProtoBrushPaint.TextureLayer.BlendMode.displayStringRId(): Int = + when (this) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC -> R.string.bg_blend_mode_src + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> R.string.bg_blend_mode_src_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> R.string.bg_blend_mode_src_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> R.string.bg_blend_mode_src_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT -> R.string.bg_blend_mode_src_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST -> R.string.bg_blend_mode_dst + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER -> R.string.bg_blend_mode_dst_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP -> R.string.bg_blend_mode_dst_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> R.string.bg_blend_mode_dst_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> R.string.bg_blend_mode_dst_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> R.string.bg_blend_mode_modulate + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR -> R.string.bg_blend_mode_xor + else -> R.string.bg_node_unknown + } + +fun ProtoBrushBehavior.Node.NodeCase.displayStringRId(): Int = + when (this) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target + else -> R.string.bg_node_unknown + } + +fun ProtoBrushFamily.InputModel.displayStringRId(): Int = + when { + hasSlidingWindowModel() -> R.string.bg_model_sliding_window + hasPassthroughModel() -> R.string.bg_model_passthrough + else -> R.string.bg_unknown_model + } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt new file mode 100644 index 00000000..d8433697 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt @@ -0,0 +1,593 @@ +/* + * * 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 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 +import java.util.UUID +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.decode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import com.example.cahier.R +import ink.proto.Color as ProtoColor + +/** + * Converts a [ProtoBrushFamily] into a functional [BrushFamily] object. + * + * This handles the necessary GZIP compression and decoding steps required by the [BrushFamily.decode] API. + */ +fun ProtoBrushFamily.toBrushFamily(): BrushFamily { + val rawBytes = this.toByteArray() + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { it.write(rawBytes) } + return ByteArrayInputStream(baos.toByteArray()).use { inputStream -> + BrushFamily.decode(inputStream) + } +} + +data class GraphPoint(val x: Float, val y: Float) + +/** Representation of a single node in the brush behavior graph. */ +data class GraphNode( + val id: String = UUID.randomUUID().toString(), + val data: NodeData, + val isExpanded: Boolean = false, + val hasError: Boolean = false, + val hasWarning: Boolean = false, + val isDisabled: Boolean = false, +) + +/** Represents the core data/component within a node. */ +sealed interface NodeData { + /** Returns a list of the input ports visible on this node */ + fun getVisiblePorts(nodeId: String, graph: BrushGraph): List = emptyList() + + /** Metadata for the inputs of this node. */ + fun inputLabels(): List = emptyList() + + /** Returns whether this node has an output port. */ + fun hasOutput(): Boolean = true + + /** Title to be displayed on the node. */ + fun title(): Int + + /** Subtitle for additional context, if any. */ + fun subtitles(): List = emptyList() + + /** Wraps a [ProtoBrushTip]. */ + data class Tip( + val tip: ProtoBrushTip, + val behaviorPortIds: List = emptyList() + ) : NodeData { + override fun inputLabels() = listOf(R.string.bg_port_behaviors) + + override fun title() = R.string.bg_tip + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (portId in behaviorPortIds) { + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_behavior))) + } + ports.add(Port.AddBehavior(nodeId, "add_behavior", label = DisplayText.Resource(R.string.bg_behavior))) + return ports + } + } + + /** Wraps a [ProtoBrushPaint]. */ + data class Paint( + val paint: ProtoBrushPaint, + val texturePortIds: List = emptyList(), + val colorPortIds: List = emptyList() + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf() + for (i in texturePortIds.indices) labels.add(R.string.bg_port_texture) + labels.add(R.string.bg_port_texture) + for (i in colorPortIds.indices) labels.add(R.string.bg_port_color) + labels.add(R.string.bg_port_color) + return labels + } + + override fun title() = R.string.bg_paint + + override fun subtitles() = listOf(DisplayText.Resource(R.string.bg_overlap_label, listOf(DisplayText.Resource(paint.selfOverlap.displayStringRId())))) + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (portId in texturePortIds) { + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_texture))) + } + ports.add(Port.AddTexture(nodeId, "add_texture", label = DisplayText.Resource(R.string.bg_port_texture))) + + for (portId in colorPortIds) { + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_color))) + } + ports.add(Port.AddColor(nodeId, "add_color", label = DisplayText.Resource(R.string.bg_port_color))) + return ports + } + } + + /** Wraps a [ProtoBrushPaint.TextureLayer]. */ + data class TextureLayer( + val layer: ProtoBrushPaint.TextureLayer + ) : NodeData { + override fun title() = R.string.bg_texture_layer + + override fun subtitles() = listOf(DisplayText.Literal(layer.clientTextureId)) + } + + /** Wraps a [ProtoColorFunction]. */ + data class ColorFunction( + val function: ProtoColorFunction + ) : NodeData { + override fun title() = R.string.bg_color_function + + override fun subtitles() = + listOf( + if (function.hasOpacityMultiplier()) { + DisplayText.Resource(R.string.bg_opacity_multiplier) + } else { + DisplayText.Resource(R.string.bg_replace_color) + } + ) + } + + /** Wraps a [ProtoBrushBehavior.Node] */ + data class Behavior( + val node: ProtoBrushBehavior.Node, + val developerComment: String = "", + val behaviorId: String = "", + val inputPortIds: List = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("value", "start", "end") + else -> emptyList() + } + ) : NodeData { + override fun inputLabels(): List { + return when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf(R.string.bg_port_a, R.string.bg_port_b) + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf(R.string.bg_port_value, R.string.bg_port_start, R.string.bg_port_end) + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf(R.string.bg_port_angle, R.string.bg_port_mag) + else -> emptyList() + } + } + + val isOperator: Boolean + get() = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE, + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE, + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE, + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE, + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE, + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> true + else -> false + } + + override fun title() = + when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target + else -> R.string.bg_node_unknown + } + + override fun subtitles(): List { + val s = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> DisplayText.Resource(node.sourceNode.source.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> DisplayText.Literal("%.2f".format(java.util.Locale.US, node.constantNode.value)) + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> + return listOf( + DisplayText.Resource(node.noiseNode.varyOver.displayStringRId()), + DisplayText.Resource(R.string.bg_period_label, listOf(node.noiseNode.basePeriod.toString())) + ) + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> { + val bitmask = node.toolTypeFilterNode.enabledToolTypes + val enabled = mutableListOf() + if (bitmask and (1 shl 0) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_unknown)) + if (bitmask and (1 shl 1) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_mouse)) + if (bitmask and (1 shl 2) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_touch)) + if (bitmask and (1 shl 3) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_stylus)) + return if (enabled.isEmpty()) listOf(DisplayText.Resource(R.string.bg_none)) + else enabled + } + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> { + val source = node.dampingNode.dampingSource + val unit = when (source) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> DisplayText.Resource(R.string.bg_unit_cm) + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> DisplayText.Resource(R.string.bg_unit_size) + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> DisplayText.Resource(R.string.bg_unit_s) + else -> DisplayText.Literal("") + } + return listOf( + DisplayText.Resource(source.displayStringRId()), + DisplayText.Resource(R.string.bg_gap_label, listOf(node.dampingNode.dampingGap.toString(), unit)) + ) + } + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> DisplayText.Resource(node.responseNode.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> DisplayText.Resource(node.integralNode.integrateOver.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> DisplayText.Resource(node.binaryOpNode.operation.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> DisplayText.Resource(node.interpolationNode.interpolation.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> DisplayText.Resource(node.targetNode.target.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> DisplayText.Resource(node.polarTargetNode.target.displayStringRId()) + else -> DisplayText.Literal(node.nodeCase.name) + } + return listOf(s) + } + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { + val labels = inputLabels() + for (i in labels.indices) { + val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[i]))) + } + } + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { + var nextIndex = 0 + for (portId in inputPortIds) { + var n = nextIndex + 1 + val builder = StringBuilder() + while (n > 0) { + val m = (n - 1) % 26 + builder.append(('A'.code + m).toChar()) + n = (n - 1) / 26 + } + val label = builder.reverse().toString() + ports.add(Port.Input(nodeId, portId, label = DisplayText.Literal(label))) + nextIndex++ + } + ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) + } + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { + val labels = inputLabels() + for (i in inputPortIds.indices) { + val label = labels[i % labels.size] + ports.add(Port.Input(nodeId, inputPortIds[i], label = DisplayText.Resource(label))) + } + ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) + } + else -> { + val labels = inputLabels() + if (labels.size == 1) { + for (portId in inputPortIds) { + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[0]))) + } + ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) + } else { + for (i in labels.indices) { + val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[i]))) + } + } + } + } + return ports + } + } + + /** Represents a [ProtoBrushCoat]. */ + data class Coat( + val tipPortId: String = "tip", + val paintPortIds: List = emptyList() + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf(R.string.bg_port_tip) + for (i in paintPortIds.indices) { + labels.add(R.string.bg_port_paint) + } + labels.add(R.string.bg_port_paint) + return labels + } + + override fun title() = R.string.bg_coat + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + ports.add(Port.AddTip(nodeId, tipPortId, label = DisplayText.Resource(R.string.bg_port_tip))) + for (portId in paintPortIds) { + ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_paint))) + } + ports.add(Port.AddPaint(nodeId, "add_paint", label = DisplayText.Resource(R.string.bg_port_paint))) + return ports + } + } + + /** Represents the [ProtoBrushFamily] root. */ + data class Family( + val clientBrushFamilyId: String = "", + val developerComment: String = "", + val inputModel: ProtoBrushFamily.InputModel = + ProtoBrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ProtoBrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + .build(), + val coatPortIds: List = emptyList(), + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf() + for (i in coatPortIds.indices) { + labels.add(R.string.bg_port_coat) + } + labels.add(R.string.bg_coat) + return labels + } + + override fun title() = R.string.bg_family + + override fun subtitles() = listOf(DisplayText.Literal(clientBrushFamilyId)) + + override fun hasOutput() = false + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (i in coatPortIds.indices) { + ports.add(Port.Input(nodeId, coatPortIds[i], label = DisplayText.Resource(R.string.bg_port_coat, listOf(i)))) + } + ports.add(Port.AddCoat(nodeId, "add_coat", label = DisplayText.Resource(R.string.bg_coat))) + return ports + } + } +} + +/** Side of a node where a port is located. */ +enum class PortSide { + INPUT, + OUTPUT, +} + +/** Represents a connection between two nodes. */ +data class GraphEdge( + val fromNodeId: String, + val toNodeId: String, + val toPortId: String, + val isDisabled: Boolean = false +) + +/** Represents the entire node graph state. */ +data class BrushGraph( + val nodes: List = emptyList(), + val edges: List = emptyList(), +) + +sealed class Port( + val nodeId: String, + val id: String, + val label: DisplayText? = null, + val isAddPort: Boolean = false +) { + abstract val side: PortSide + + class Output(nodeId: String, id: String = "output", label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = false) { + override val side = PortSide.OUTPUT + } + + class Input(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = false) { + override val side = PortSide.INPUT + } + + class AddCoat(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddBehavior(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddInput(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddTexture(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddColor(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddTip(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + class AddPaint(nodeId: String, id: String, label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } +} + +fun GraphNode.getVisiblePorts(graph: BrushGraph): List { + return data.getVisiblePorts(this.id, graph) +} + +/** Preserves input edges when changing node types by mapping them to new port IDs. */ +fun preserveEdgesOnTypeChange( + nodeId: String, + oldData: NodeData?, + newData: NodeData, + edges: List +): Pair> { + var finalNewData = newData + var finalEdges = edges + + if (oldData is NodeData.Behavior && newData is NodeData.Behavior) { + val oldCase = oldData.node.nodeCase + val newCase = newData.node.nodeCase + if (oldCase != newCase) { + val incomingEdges = edges.filter { it.toNodeId == nodeId } + val newIds = mutableListOf() + val updatedEdges = mutableListOf() + + when (newCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { + val defaultIds = listOf("value", "start", "end") + for (i in 0..2) { + val edge = incomingEdges.getOrNull(i) + val portId = edge?.toPortId ?: defaultIds[i] + newIds.add(portId) + if (edge != null) { + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + } + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { + incomingEdges.take(26).forEachIndexed { index, edge -> + val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { + incomingEdges.forEachIndexed { index, edge -> + val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + else -> { + val labels = newData.inputLabels() + if (labels.size == 1) { + incomingEdges.forEachIndexed { index, edge -> + val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + } + } + + finalNewData = newData.copy(inputPortIds = newIds) + val edgesWithoutIncoming = edges.filter { it.toNodeId != nodeId } + finalEdges = edgesWithoutIncoming + updatedEdges + } else if (newData.inputPortIds.isEmpty() && oldData.inputPortIds.isNotEmpty()) { + finalNewData = newData.copy(inputPortIds = oldData.inputPortIds) + } + } + return Pair(finalNewData, finalEdges) +} + +/** Based on [Port] subtype, create default [NodeData] to populate it. */ +fun Port.inferNodeData(node: GraphNode): NodeData? = when (this) { + is Port.AddCoat -> NodeData.Coat() + is Port.AddTip -> NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + is Port.AddPaint -> NodeData.Paint(ProtoBrushPaint.getDefaultInstance()) + is Port.AddTexture -> NodeData.TextureLayer(ProtoBrushPaint.TextureLayer.getDefaultInstance()) + is Port.AddColor -> NodeData.ColorFunction(ProtoColorFunction.newBuilder() + .setReplaceColor( + ProtoColor.newBuilder() + .setRed(0f) + .setGreen(0f) + .setBlue(0f) + .setAlpha(1f) + .build() + ).build()) + is Port.AddBehavior -> { + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setTargetNode( + ProtoBrushBehavior.TargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) + .setTargetModifierRangeStart(0.0f) + .setTargetModifierRangeEnd(1.0f) + ) + .build(), + "", + UUID.randomUUID().toString() + ) + } + is Port.AddInput -> { + val data = node.data as NodeData.Behavior + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + .setSourceValueRangeStart(0.0f) + .setSourceValueRangeEnd(1.0f) + ) + .build(), + "", + data.behaviorId + ) + } + is Port.Input -> { + val data = node.data + if (data is NodeData.Behavior) { + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + .setSourceValueRangeStart(0.0f) + .setSourceValueRangeEnd(1.0f) + ) + .build(), + "", + data.behaviorId + ) + } else { + null + } + } + else -> null +} + +/** Determines if a given [Port] can be reordered with drag handles in the UI. */ +fun NodeData.isPortReorderable(port: Port, index: Int, hasAddPort: Boolean): Boolean { + return !port.isAddPort && hasAddPort && when (this) { + is NodeData.Coat -> index != 0 + is NodeData.Behavior -> { + if (this.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + index % 2 == 0 + } else { + true + } + } + else -> true + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt new file mode 100644 index 00000000..7d524f40 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt @@ -0,0 +1,500 @@ +/* + * * 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.R +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushPaint as ProtoBrushPaint + +/** The severity of a validation issue. Errors and Warnings are generally associated + * with specific issues with nodes, and link to them, while Debug messages are general + * information. Errors represent blocking issues which cause the [BrushGraph] to fail + * validation, while Warnings are non-blocking, but should be fixed, and can help diagnose + * issues with the [BrushGraph]. Errors are downgraded to Warnings when the affected node + * is orphaned from the graph, so a node not included in the graph doesn't block validation. + */ +enum class ValidationSeverity { + ERROR, + WARNING, + DEBUG, +} + +/** Exception thrown when the brush graph fails validation. */ +data class GraphValidationException( + val displayMessage: DisplayText, + val nodeId: String? = null, + val severity: ValidationSeverity = ValidationSeverity.ERROR, +) : IllegalStateException( + when (displayMessage) { + is DisplayText.Literal -> displayMessage.text + is DisplayText.Resource -> "Resource ${displayMessage.resId}" + } +) + +/** Utility to validate a [BrushGraph] for correctness. */ +object GraphValidator { + + /** Validates the entire graph and returns all found errors and warnings. */ + fun validateAll(graph: BrushGraph): List { + val issues = mutableListOf() + val activeNodeIds = findActiveNodes(graph) + + val nodesById = graph.nodes.associateBy { it.id } + + // Check for dangling edges. + for (edge in graph.edges) { + if (edge.isDisabled) continue + if (!nodesById.containsKey(edge.fromNodeId)) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_source), nodeId = edge.toNodeId)) + } + if (!nodesById.containsKey(edge.toNodeId)) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_target), nodeId = edge.fromNodeId)) + } + } + + // Input labels and required connections. + val familyNodes = graph.nodes.filter { it.data is NodeData.Family } + if (familyNodes.size != 1) { + for (node in familyNodes) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_count, listOf(familyNodes.size)), + nodeId = node.id, + severity = ValidationSeverity.ERROR, + ) + ) + } + if (familyNodes.isEmpty()) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_count, listOf(0)), + severity = ValidationSeverity.ERROR, + ) + ) + } + } + + for (node in graph.nodes) { + if (node.isDisabled) continue + val isActive = activeNodeIds.contains(node.id) + val ports = node.getVisiblePorts(graph) + val isOptionalInput = node.data is NodeData.Tip || node.data is NodeData.Paint + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) } + + val connectedPortIds = incomingEdges.map { it.toPortId }.toSet() + val active = isActive + + when (val data = node.data) { + is NodeData.Coat -> { + val hasTip = connectedPortIds.contains(data.tipPortId) + val hasPaint = data.paintPortIds.any { connectedPortIds.contains(it) } + if (!hasTip) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_tip), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + if (!hasPaint) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_paint), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } + is NodeData.Behavior -> { + val nodeCase = data.node.nodeCase + val labels = data.inputLabels() + val ids = if (data.inputPortIds.isEmpty()) { + when (nodeCase) { + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf("input_0", "input_1") + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf("angle_0", "mag_0") + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("value", "start", "end") + else -> if (labels.size == 1) listOf("Input") else emptyList() + } + } else data.inputPortIds + + if (nodeCase == ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE) { + val labels = listOf(R.string.bg_port_value, R.string.bg_port_start, R.string.bg_port_end) + for (i in 0 until minOf(ids.size, labels.size)) { + if (!connectedPortIds.contains(ids[i])) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Resource(labels[i]))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } + } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val chunkedIds = ids.chunked(2) + val hasValidSet = chunkedIds.any { set -> set.size == 2 && set.all { connectedPortIds.contains(it) } } + if (!hasValidSet) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_polar_missing_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE) { + val numInputs = ids.count { connectedPortIds.contains(it) } + if (numInputs < 2) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_binary_min_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } else if (numInputs > 26) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_binary_max_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } else { + if (connectedPortIds.isEmpty() && data.inputLabels().isNotEmpty()) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_missing_input, listOf(DisplayText.Resource(data.title()))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } + } + + is NodeData.Family -> { + if (connectedPortIds.isEmpty()) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_family_missing_coat), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } + else -> { + if (!isOptionalInput && data.inputLabels().isNotEmpty() && connectedPortIds.isEmpty()) { + issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_missing_input, listOf(DisplayText.Resource(data.title()))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + } + } + } + + for (edge in incomingEdges) { + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + if (fromNode == null) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_invalid_conn_no_source), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } else { + val actualSources = findActualSourceNode(graph, edge.fromNodeId) + if (actualSources.isEmpty()) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_missing_source_passthrough), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } else { + for (actualSourceNode in actualSources) { + isValidConnection(actualSourceNode, node, edge.toPortId, graph)?.let { displayText -> + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_invalid_connection_detail, listOf(DisplayText.Resource(actualSourceNode.data.title()), DisplayText.Resource(node.data.title()), edge.toPortId, displayText)), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } + } + } + } + } + + if (node.data is NodeData.Family) { + if (graph.edges.none { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) }) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_no_coat), + nodeId = node.id, + ValidationSeverity.ERROR, + ) + ) + } + } + + if (node.data !is NodeData.Family && node.data.hasOutput()) { + if (graph.edges.none { !it.isDisabled && it.fromNodeId == node.id && activeNodeIds.contains(it.toNodeId) }) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_unused_output, listOf(DisplayText.Resource(node.data.title()))), + nodeId = node.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + } + + if (node.data is NodeData.Coat) { + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) } + val tipEdge = incomingEdges.find { it.toPortId == node.data.tipPortId } + + val connectedPaints = node.data.paintPortIds.mapNotNull { portId -> + incomingEdges.find { it.toPortId == portId } + }.mapNotNull { edge -> + graph.nodes.find { it.id == edge.fromNodeId } + } + + if (tipEdge != null && connectedPaints.isNotEmpty()) { + val discardPaints = connectedPaints.filter { + it.data is NodeData.Paint && + it.data.paint.selfOverlap == ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD + } + + if (discardPaints.isNotEmpty()) { + val opacityTargetNodes = mutableListOf() + findOpacityTargetNodes(tipEdge.fromNodeId, graph, mutableSetOf(), opacityTargetNodes) + + if (opacityTargetNodes.isNotEmpty()) { + for (paintNode in discardPaints) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_self_overlap_incompatible_op), + nodeId = paintNode.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + opacityTargetNodes.forEach { targetNode -> + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_op_incompatible_self_overlap), + nodeId = targetNode.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + } + } + } + } + + if (node.data is NodeData.Behavior) { + val behaviorNode = node.data.node + if (behaviorNode.nodeCase == ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE) { + val sourceNode = behaviorNode.sourceNode + if (sourceNode.sourceValueRangeStart == sourceNode.sourceValueRangeEnd) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_source_range_equal, node.data.subtitles()), + nodeId = node.id, + severity = if (isActive) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } + } + } + } + + val visited = mutableSetOf() + for (node in graph.nodes) { + if (!visited.contains(node.id)) { + try { + checkCycle(node.id, graph, visited, mutableSetOf()) + } catch (e: GraphValidationException) { + issues.add( + if (activeNodeIds.contains(e.nodeId)) e else e.copy(severity = ValidationSeverity.WARNING) + ) + } + } + } + + return issues.distinct() + } + + /** Returns a failure message when a connection from [from] to [to] at [toPortId] is invalid. */ + fun isValidConnection(from: GraphNode, to: GraphNode, toPortId: String, graph: BrushGraph = BrushGraph()): DisplayText? { + val fromData = from.data + val toData = to.data + val fromIsStructural = + fromData is NodeData.Tip || + fromData is NodeData.Coat || + fromData is NodeData.Paint || + fromData is NodeData.TextureLayer || + fromData is NodeData.ColorFunction || + fromData is NodeData.Family + val toIsStructural = + toData is NodeData.Tip || + toData is NodeData.Coat || + toData is NodeData.Paint || + toData is NodeData.TextureLayer || + toData is NodeData.ColorFunction || + toData is NodeData.Family + + val toPort = to.getVisiblePorts(graph).find { it.id == toPortId } + + return when (toData) { + is NodeData.Coat -> { + val coatData = toData + if (toPortId == coatData.tipPortId) { + if (fromData is NodeData.Tip) { + null + } else { + DisplayText.Resource(R.string.bg_err_coat_only_accepts_tip) + } + } else if (coatData.paintPortIds.contains(toPortId) || toPort is Port.AddPaint) { + if (fromData is NodeData.Paint) { + null + } else { + DisplayText.Resource(R.string.bg_err_coat_only_accepts_paint) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_coat) + } + } + is NodeData.Family -> { + val familyData = toData + if (familyData.coatPortIds.contains(toPortId) || toPort is Port.AddCoat) { + if (fromData is NodeData.Coat) { + null + } else { + DisplayText.Resource(R.string.bg_err_family_only_accepts_coat) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_family) + } + } + is NodeData.Tip -> { + if ( + !(fromData is NodeData.Behavior) || + (fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) + ) { + DisplayText.Resource(R.string.bg_err_tip_only_accepts_target) + } else { + null + } + } + is NodeData.Paint -> { + if (toData.texturePortIds.contains(toPortId) || toPort is Port.AddTexture) { + if (fromData is NodeData.TextureLayer) { + null + } else { + DisplayText.Resource(R.string.bg_err_paint_only_accepts_texture) + } + } else if (toData.colorPortIds.contains(toPortId) || toPort is Port.AddColor) { + if (fromData is NodeData.ColorFunction) { + null + } else { + DisplayText.Resource(R.string.bg_err_paint_only_accepts_color) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_paint) + } + } + is NodeData.TextureLayer -> DisplayText.Resource(R.string.bg_err_texture_cannot_accept_inputs) + is NodeData.ColorFunction -> DisplayText.Resource(R.string.bg_err_color_cannot_accept_inputs) + else -> { + // 'to' is a behavior node. + if ( + fromData is NodeData.Behavior && + (fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.TARGET_NODE || + fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) + ) { + // Targets can only connect to Tip. + DisplayText.Resource( + R.string.bg_err_behavior_cannot_accept, + listOf(DisplayText.Resource(toData.title()), DisplayText.Resource(fromData.title())) + ) + } else if (!fromIsStructural && !toIsStructural) { + null + } else { + DisplayText.Resource( + R.string.bg_err_behavior_cannot_accept_structural, + listOf(DisplayText.Resource(toData.title()), DisplayText.Resource(fromData.title())) + ) + } + } + } + } + + /** Returns the set of node IDs for active (not disabled) nodes in the [BrushGraph] */ + private fun findActiveNodes(graph: BrushGraph): Set { + val familyNode = graph.nodes.find { it.data is NodeData.Family } ?: return emptySet() + if (familyNode.isDisabled) return emptySet() + val active = mutableSetOf(familyNode.id) + val queue = mutableListOf(familyNode.id) + while (queue.isNotEmpty()) { + val currentId = queue.removeAt(0) + val currentNode = graph.nodes.find { it.id == currentId } + val isPassThrough = currentNode != null && currentNode.isDisabled && + currentNode.data is NodeData.Behavior && currentNode.data.isOperator + + val currentNodeData = currentNode?.data as? NodeData.Behavior + val firstPortId = currentNodeData?.inputPortIds?.firstOrNull() + + for (edge in graph.edges.filter { !it.isDisabled && it.toNodeId == currentId }) { + if (isPassThrough && edge.toPortId != firstPortId) continue + + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + if (fromNode != null) { + val isFromPassThrough = fromNode.isDisabled && + fromNode.data is NodeData.Behavior && fromNode.data.isOperator + + if (!fromNode.isDisabled || isFromPassThrough) { + if (active.add(edge.fromNodeId)) queue.add(edge.fromNodeId) + } + } + } + } + return active + } + + /** Returns input nodes to disabled node, or returns the node itself. This logic enables data + * incoming to disabled nodes to "pass through" to where the disabled node is going. + */ + fun findActualSourceNode(graph: BrushGraph, nodeId: String): List { + val node = graph.nodes.find { it.id == nodeId } ?: return emptyList() + if (!node.isDisabled) return listOf(node) + if (node.data is NodeData.Behavior && node.data.isOperator) { + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + val sources = mutableListOf() + for (edge in incomingEdges) { + sources.addAll(findActualSourceNode(graph, edge.fromNodeId)) + } + return sources + } + return emptyList() + } + + private fun checkCycle( + nodeId: String, + graph: BrushGraph, + visited: MutableSet, + path: MutableSet, + ) { + if (!path.add(nodeId)) { + throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_cycle_detected, listOf(nodeId)), nodeId = nodeId) + } + visited.add(nodeId) + for (edge in graph.edges.filter { it.fromNodeId == nodeId }) { + checkCycle(edge.toNodeId, graph, visited, path) + } + path.remove(nodeId) + } + + private fun findOpacityTargetNodes( + nodeId: String, + graph: BrushGraph, + visited: MutableSet, + results: MutableList, + ) { + if (!visited.add(nodeId)) return + val node = graph.nodes.find { it.id == nodeId } ?: return + if (node.isDisabled) return + + if (node.data is NodeData.Behavior) { + val brushNode = node.data.node + if ( + brushNode.hasTargetNode() && + brushNode.targetNode.target == ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER + ) { + results.add(node) + } + } + + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + for (edge in incomingEdges) { + findOpacityTargetNodes(edge.fromNodeId, graph, visited, results) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt new file mode 100644 index 00000000..ad4ff65e --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt @@ -0,0 +1,598 @@ +/* + * * 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.R +import ink.proto.BrushBehavior + +/** + * Represents a step in the guided tutorial. + */ +data class TutorialStep( + val title: Int, + val message: Int, + val anchor: TutorialAnchor, + val actionRequired: TutorialAction, + val getTargetNode: (BrushGraph) -> GraphNode? = { null } +) + +enum class TutorialAnchor { + SCREEN_CENTER, + FAB, + NODE_CANVAS, + INSPECTOR, + TEST_CANVAS, + ACTION_BAR, + NOTIFICATION_ICON +} + +enum class TutorialAction { + CLICK_NEXT, + CONNECT_NODES, + SELECT_NODE, + EDIT_FIELD, + DRAW_ON_CANVAS, + DELETE_NODE, + SELECT_EDGE, + USE_ACTION_BAR, + CHECK_NOTIFICATIONS, + ADD_BEHAVIOR, + CLICK_NOTIFICATION, + CLICK_ERROR_LINK, + ADD_INPUT_FAB, + MOVE_NODE, + EXIT_INSPECTOR, + EDIT_DROPDOWN, + ADD_NODE_BETWEEN, + LONG_PRESS_NODE, + DUPLICATE_NODES, + SWAP_PORTS, + ADD_COLOR, + CLICK_DONE +} + +val TUTORIAL_STEPS = listOf( + TutorialStep( + title = R.string.bg_tutorial_welcome_title, + message = R.string.bg_tutorial_welcome_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_test_canvas_title, + message = R.string.bg_tutorial_test_canvas_message, + anchor = TutorialAnchor.TEST_CANVAS, + actionRequired = TutorialAction.DRAW_ON_CANVAS + ), + TutorialStep( + title = R.string.bg_tutorial_test_canvas_options_title, + message = R.string.bg_tutorial_test_canvas_options_message, + anchor = TutorialAnchor.TEST_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_edit_tip_title, + message = R.string.bg_tutorial_edit_tip_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Tip } } + ), + TutorialStep( + title = R.string.bg_tutorial_modify_tip_shape_title, + message = R.string.bg_tutorial_modify_tip_shape_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_live_strokes_title, + message = R.string.bg_tutorial_live_strokes_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_inspector_features_title, + message = R.string.bg_tutorial_inspector_features_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_exit_inspector_title, + message = R.string.bg_tutorial_exit_inspector_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EXIT_INSPECTOR + ), + TutorialStep( + title = R.string.bg_tutorial_brush_behaviors_title, + message = R.string.bg_tutorial_brush_behaviors_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_behavior_title, + message = R.string.bg_tutorial_add_behavior_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_BEHAVIOR, + getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Tip } } + ), + TutorialStep( + title = R.string.bg_tutorial_error_title, + message = R.string.bg_tutorial_error_message, + anchor = TutorialAnchor.NOTIFICATION_ICON, + actionRequired = TutorialAction.CLICK_NOTIFICATION + ), + TutorialStep( + title = R.string.bg_tutorial_notification_pane_title, + message = R.string.bg_tutorial_notification_pane_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_navigate_to_nodes_with_issues_title, + message = R.string.bg_tutorial_navigate_to_nodes_with_issues_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_ERROR_LINK + ), + TutorialStep( + title = R.string.bg_tutorial_add_input_title, + message = R.string.bg_tutorial_add_input_message, + anchor = TutorialAnchor.FAB, + actionRequired = TutorialAction.ADD_INPUT_FAB + ), + TutorialStep( + title = R.string.bg_tutorial_change_node_type_title, + message = R.string.bg_tutorial_change_node_type_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_move_node_title, + message = R.string.bg_tutorial_move_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.MOVE_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_connect_nodes_title, + message = R.string.bg_tutorial_connect_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_edit_target_title, + message = R.string.bg_tutorial_edit_target_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_set_target_title, + message = R.string.bg_tutorial_set_target_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_target_range_sliders_title, + message = R.string.bg_tutorial_target_range_sliders_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_target_range_title, + message = R.string.bg_tutorial_set_target_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_select_source_node_title, + message = R.string.bg_tutorial_select_source_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_set_source_type_title, + message = R.string.bg_tutorial_set_source_type_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_source_range_sliders_title, + message = R.string.bg_tutorial_source_range_sliders_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_source_range_title, + message = R.string.bg_tutorial_set_source_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_effects_of_range_title, + message = R.string.bg_tutorial_effects_of_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_explain_behavior_title, + message = R.string.bg_tutorial_explain_behavior_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_out_of_range_behavior_title, + message = R.string.bg_tutorial_out_of_range_behavior_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_clamp_title, + message = R.string.bg_tutorial_clamp_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_mirror_and_repeat_title, + message = R.string.bg_tutorial_mirror_and_repeat_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_moving_on_to_complex_behavior_title, + message = R.string.bg_tutorial_moving_on_to_complex_behavior_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_narrow_range_title, + message = R.string.bg_tutorial_set_narrow_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_set_clamp_title, + message = R.string.bg_tutorial_set_clamp_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_move_node_space_title, + message = R.string.bg_tutorial_move_node_space_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.MOVE_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_click_edge_title, + message = R.string.bg_tutorial_click_edge_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_EDGE + ), + TutorialStep( + title = R.string.bg_tutorial_edge_inspector_title, + message = R.string.bg_tutorial_edge_inspector_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_node_navigation_title, + message = R.string.bg_tutorial_node_navigation_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_back_to_edge_inspector_title, + message = R.string.bg_tutorial_back_to_edge_inspector_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_EDGE + ), + TutorialStep( + title = R.string.bg_tutorial_between_two_nodes_title, + message = R.string.bg_tutorial_between_two_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_NODE_BETWEEN + ), + TutorialStep( + title = R.string.bg_tutorial_edit_node_between_title, + message = R.string.bg_tutorial_edit_node_between_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.RESPONSE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_binary_op_title, + message = R.string.bg_tutorial_binary_op_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_input_to_binary_op_title, + message = R.string.bg_tutorial_add_input_to_binary_op_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.ADD_BEHAVIOR, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_configure_new_node_title, + message = R.string.bg_tutorial_configure_new_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_other_origins_of_values_title, + message = R.string.bg_tutorial_other_origins_of_values_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_change_to_constant_title, + message = R.string.bg_tutorial_change_to_constant_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_slide_constant_value_title, + message = R.string.bg_tutorial_slide_constant_value_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_constant_value_effects_title, + message = R.string.bg_tutorial_constant_value_effects_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_change_operation_title, + message = R.string.bg_tutorial_change_operation_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_select_product_title, + message = R.string.bg_tutorial_select_product_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_back_to_constant_title, + message = R.string.bg_tutorial_back_to_constant_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_change_constant_value_title, + message = R.string.bg_tutorial_change_constant_value_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_constant_effect_title, + message = R.string.bg_tutorial_constant_effect_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_multiplying_vs_adding_title, + message = R.string.bg_tutorial_multiplying_vs_adding_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_further_exploration_title, + message = R.string.bg_tutorial_further_exploration_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_another_coat_title, + message = R.string.bg_tutorial_add_another_coat_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.LONG_PRESS_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_select_nodes_title, + message = R.string.bg_tutorial_select_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_duplicate_nodes_title, + message = R.string.bg_tutorial_duplicate_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.DUPLICATE_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_move_duplicated_nodes_title, + message = R.string.bg_tutorial_move_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_DONE + ), + TutorialStep( + title = R.string.bg_tutorial_warning_title, + message = R.string.bg_tutorial_warning_message, + anchor = TutorialAnchor.NOTIFICATION_ICON, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_connect_to_family_title, + message = R.string.bg_tutorial_connect_to_family_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_modify_second_coat_title, + message = R.string.bg_tutorial_modify_second_coat_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_create_border_effect_title, + message = R.string.bg_tutorial_create_border_effect_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_add_color_function_title, + message = R.string.bg_tutorial_add_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_COLOR + ), + TutorialStep( + title = R.string.bg_tutorial_coat_order_significance_title, + message = R.string.bg_tutorial_coat_order_significance_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_swap_coat_order_title, + message = R.string.bg_tutorial_swap_coat_order_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SWAP_PORTS, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Family + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_change_order_by_editing_edges_title, + message = R.string.bg_tutorial_change_order_by_editing_edges_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Family + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_back_color_function_title, + message = R.string.bg_tutorial_back_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.ColorFunction + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_change_function_type_title, + message = R.string.bg_tutorial_change_function_type_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_configure_color_function_title, + message = R.string.bg_tutorial_configure_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_modify_border_color_title, + message = R.string.bg_tutorial_modify_border_color_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_configure_constant_title, + message = R.string.bg_tutorial_configure_constant_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_border_pattern_title, + message = R.string.bg_tutorial_border_pattern_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_cleanup_title, + message = R.string.bg_tutorial_cleanup_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.LONG_PRESS_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_select_duplicated_nodes_title, + message = R.string.bg_tutorial_select_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_delete_duplicated_nodes_title, + message = R.string.bg_tutorial_delete_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.DELETE_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_reuse_behavior_title, + message = R.string.bg_tutorial_reuse_behavior_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_multiple_outputs_title, + message = R.string.bg_tutorial_multiple_outputs_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_complete_title, + message = R.string.bg_tutorial_complete_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ) +) 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 653a761c..e3c45107 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 @@ -208,12 +208,12 @@ fun SettingsScreen( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.settings_node_graph_ui), + text = stringResource(R.string.settings_graph_ui), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = stringResource(R.string.settings_node_graph_coming_soon), + text = stringResource(R.string.settings_graph_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7c9403b4..701a0446 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,7 +255,769 @@ Ink Brush Designer Create, test, and export custom .brush files Launch - Node Graph UI - Coming soon + Graph UI + Visual, node-based brush designer - \ No newline at end of file + + Name Texture + Texture ID + Save to Palette + Brush Name + Clear Graph + Are you sure you want to clear the entire brush graph? This action cannot be undone. + Start Tutorial + Starting the tutorial will clear your current brush graph to start from scratch. Your current brush will be saved and restored when you exit the tutorial. + Exit Tutorial + Do you want to keep the brush you built in the tutorial, or restore your original brush? + Keep Tutorial Brush + Restore Original Brush + Options + Feedback + Lock text fields + Reorganize Graph + Are you sure you want to reorganize the graph? This will reset all node positions and expansion states. This action cannot be undone. + Select + Export + Import + Organize + Templates + Pressure Pen + Delete Brush + No saved brushes yet + Coat + Paint + Tip + Behavior + Color Function + Texture Layer + My Palette + Delete Edge + Are you sure you want to delete this edge? + Add Node Between + Disable edge + Delete Node + Are you sure you want to delete this node and all its connections? + Disable node + Auto-update + %1$dpx + Node Type + Developer comment + Source + Out of Range Behavior + Vary Over + Enabled Tool Types: + Damping Source + Integrate Over + Operation + Interpolation + Target + Polar Target + Fallback Filter Node + No node data set + any + accumulate + discard + brush size + stroke coordinates + stroke space origin + first stroke input + last stroke input + tiling + stamping + repeat + mirror + clamp + source + source over + source atop + source in + source out + destination + destination over + destination atop + destination in + destination out + modulate + xor + Unknown Behavior Node Type + Connect Tip and Paint Preference(s) to this node. Only one Paint Preference is rendered. The system renderer will use the first compatible Paint Preference. + Self Overlap + Function Type + Color: + Client Brush Family ID + Brush developer comment + Input Model + Delete Nodes + Are you sure you want to delete the selected nodes and all their connections? + Select All + Duplicate + Unknown curve type + Points + Step Position + Predefined Function + Predefined + Cubic Bezier + Steps + Linear + None + Mapping Mode + Size Unit + Origin + Wrap X + Wrap Y + Blend Mode + Mapping + How is my texture imported? + Positioning + How is my texture drawn relative to the stroke? + Wrapping + What happens when the stroke draws further than the bounds of my texture? + Blending + How does my texture combine with the regular ink of the stroke? + Back + Next + Got it + Delete + Create Node + Exit + More options + Close Pane + Notifications (%1$d) + Errors + Warnings + Debug + Add Point + Remove Point + Add + Reorder + Upload Texture + Help + Show Notifications + Source + Constant + Noise + Tool Type Filter + Damping + Response + Integral + Binary Op + Interpolation + Target + Polar Target + Unknown + Color Function + + Edge refers to missing source node + Edge refers to missing target node + Graph must have exactly one Brush Family node. Found %1$d. + Coat missing Tip input. + Coat missing at least one Paint input. + Interpolation missing input for \"%1$s\". + Polar Target needs at least one complete set of inputs (Angle and Magnitude). + Binary Op requires at least 2 inputs. + Binary Op cannot have more than 26 inputs. + %1$s missing input. + Family missing coat input. + Invalid connection: from node not found. + Missing source for pass-through connection + Invalid connection from %1$s to %2$s at port %3$s: %4$s + Brush Family must be connected to at least one coat. + %1$s output is not used. + Self overlap discard is incompatible with an opacity multiplier target on the coat tip. + Targeting opacity multiplier is incompatible with self overlap discard on the coat paint. + Source node \"%1$s\" cannot have equal range start and end values. + Cycle detected involving node %1$s. + + Coat node %1$s not found + Node %1$s not found + Graph must have exactly one Brush Family node + Coat node %1$s missing tip input + Tip node %1$s not found + Coat node %1$s missing paint input + Paint node %1$s not found + Expected %1$s node, found %2$s + Internal error during conversion: %1$s + Unsupported behavior node type: %1$s + Failed to load brush: Legacy format not supported yet. + Brush loaded successfully + Failed to load brush: %1$s + Brush exported successfully + Failed to export brush: %1$s + Graph cleared + Graph reorganized successfully + Reorganization failed + Failed to load brush + Cannot delete Family node + Behavior node %1$s cannot accept input from %2$s + Behavior node %1$s cannot accept input from structural node %2$s + Coat can only accept input from Tip at the tip port + Coat can only accept input from Paint at paint ports + Paint can only accept input from TextureLayer at Texture ports + Paint can only accept input from ColorFunction at Color ports + Tip can only accept input from Target or PolarTarget + Family can only connect to Coat + TextureLayer cannot accept inputs + ColorFunction cannot accept inputs + none + Invalid port for Coat + Invalid port for Family + Invalid port for Paint + + Behaviors + Texture + Color + Input + A + B + Value + Start + End + Angle + Mag + + opacity multiplier + replace color + + unknown + mouse + touch + stylus + none + + cm + size + s + + overlap: %1$s + period: %1$s + gap: %1$s%2$s + normalized pressure + tilt + tilt X + tilt Y + orientation + orientation about zero + speed + velocity X + velocity Y + direction + direction about zero + normalized direction X + normalized direction Y + distance traveled + time of input (s) + time of input (ms) + predicted distance traveled + predicted time elapsed (s) + predicted time elapsed (ms) + distance remaining + time since input (s) + time since input (ms) + time since stroke end + acceleration + acceleration X + acceleration Y + acceleration forward + acceleration lateral + speed (absolute) + velocity X (absolute) + velocity Y (absolute) + distance traveled (absolute) + predicted distance traveled (absolute) + acceleration (absolute) + acceleration X (absolute) + acceleration Y (absolute) + acceleration forward (absolute) + acceleration lateral (absolute) + distance remaining (fraction) + width multiplier + height multiplier + size multiplier + slant offset + pinch offset + rotation offset + corner rounding offset + position offset X + position offset Y + position offset forward + position offset lateral + hue offset + saturation multiplier + luminosity offset + opacity multiplier + position offset absolute + position offset relative + product + sum + min + max + and then + or else + xor else + clamp + repeat + mirror + distance (absolute) + distance (size-relative) + time (seconds) + lerp + inverse lerp + Coat %1$d + Tip + Paint Preference + Behavior + Family + Start + Reorganize + OK + Cancel + Predefined Easing + Cubic Bezier + Linear + Steps + + + Start nodes: + Operator nodes: + Terminal nodes: + Size \u0026 Shape: + Color \u0026 Opacity: + + Range Start + Range End + Base Period + Damping Gap + Angle Start + Angle End + Mag Start + Mag End + Scale X + Scale Y + Corner Rounding + Slant Degrees + Rotation Degrees + Particle Gap Distance Scale + Particle Gap Duration (ms) + Window Size (ms) + Upsampling Frequency (Hz) + + Out of Range Behavior: %1$s + Vary Over: %1$s + Damping Source: %1$s + Operation: %1$s + Interpolation: %1$s + Target: %1$s + Polar Target: %1$s + Self Overlap: %1$s + Function Type: %1$s + Input Model: %1$s + + This source is only compatible with \'clamp\' behavior. + Sliding Window Model + Passthrough Model + Input: + Movement: + Distance: + Time: + Acceleration: + Source: %1$s + Seed + Position: + Pinch + Unknown Model + Exit Tutorial + Tutorial + Custom Brushes + Size X + Size Y + Offset X + Offset Y + Draw here to test + Collapse + Expand + Test canvas + Reset + Invert canvas + Out + Step Count + Opacity Multiplier + + + Defines the geometric shape and size of the brush tip. This shape acts as a cross-section that is repeated or extruded along the path to create the stroke mesh. + Controls the shading, color, and texturing applied to the stroke geometry. A Coat can have a list of Paint preferences for compatibility fallback; the first one compatible with the device and renderer is used. + Retrieves data from the device input (such as pressure, tilt, or speed) and maps it to a normalized 0 to 1 range. This allows you to use physical input to drive brush behavior dynamically. + Produces a single, fixed numeric value that does not change. This is useful for providing a steady baseline or default value to other nodes in the graph. + Generates a continuous random noise function with values between 0 and 1. It creates organic, non-repeating variations based on a domain like time or distance, allowing for natural-looking brush effects. + Filters out a branch of the behavior graph unless a specific input property (like pressure or tilt) is missing from the device. This node is deprecated in favor of more flexible fallback operations. + Passes or blocks values based on the type of tool being used (e.g., stylus, finger, or mouse). This allows you to create brush behaviors that only apply to specific input methods. + Smoothes out rapid changes in an input value, causing the output to gradually follow the input over time or distance. This is useful for reducing jitter and creating smoother brush strokes. + Maps an input value through a custom response curve or easing function. This allows you to reshape the input data, for example, to make a brush more or less sensitive to pressure. + Combines two input values using a standard binary operation such as addition, multiplication, minimum, or maximum. This allows you to mix different behavior branches together. + Performs interpolation between two values based on a third control value. For example, you can use this to blend between two different brush sizes based on pressure. + Accumulates or integrates an input value over time or distance since the start of the stroke. This is useful for effects that build up as you draw, like ink bleeding or texture accumulation. + Applies the final calculated value to a specific property of the brush tip, such as width, height, or color. This is a terminal node, meaning it does not pass values further but effects the actual rendering. + Applies a vector modifier (angle and magnitude) to a property of the brush tip, such as position offset. This allows for directional effects, like offsetting the tip based on the direction of travel. + A behavior node that modifies brush properties based on inputs and operations. + Configures a texture layer for the paint, specifying the image, size, mapping mode, and how it blends with the base color. + Configures a color function for the paint, allowing for dynamic color shifts based on behavior graph outputs. + Configures a layer of the brush, which can have its own tip and paint settings, allowing for complex multi-layered brushes. + The root node representing the complete brush family. It contains one or more coats that define the final appearance of the brush. + + + Measures the stylus or touch pressure, reported in a normalized range from 0 to 1. This is the most common source for controlling brush thickness or opacity based on how hard the user presses. + Measures the tilt of the stylus relative to the screen, reported in radians from 0 (perpendicular) to π/2 (parallel). This can be used to simulate calligraphy or airbrush effects by changing tip shape based on angle. + Absolute speed of the modeled stroke input in multiples of the brush size per second. This allows you to make the brush respond to how fast the user is drawing, for example, making it thinner at high speeds. + Signed X component of the velocity of the modeled stroke input in multiples of the brush size per second. Positive values indicate movement toward the right. + Signed Y component of the velocity of the modeled stroke input in multiples of the brush size per second. Positive values indicate movement downward. + Signed X component of the modeled stroke input\'s current direction of travel, normalized to the range [-1, 1]. It indicates the horizontal direction the stroke is moving. + Signed Y component of the modeled stroke input\'s current direction of travel, normalized to the range [-1, 1]. It indicates the vertical direction the stroke is moving. + Distance traveled by the inputs of the current stroke, starting at 0 at the first input, where one distance unit is equal to the brush size. This is useful for effects that change progressively along the stroke. + The time elapsed (in seconds) from when the stroke started to when this part of the stroke was drawn. The value remains fixed for any given part of the stroke once drawn. + The time elapsed (in milliseconds) from when the stroke started. This is deprecated; use SOURCE_TIME_OF_INPUT_IN_SECONDS instead. + Distance traveled by the inputs of the current prediction, starting at 0 at the last non-predicted input, in multiples of the brush size. + Elapsed time (in seconds) of the prediction, starting at 0 at the last non-predicted input. + Stylus tilt along the horizontal axis, in radians. Positive values correspond to tilt toward the right. + Stylus tilt along the vertical axis, in radians. Positive values correspond to tilt downward. + Stylus orientation or angle relative to the screen, in radians from 0 to 2π. + Stylus orientation centered around zero, in radians from -π to π. + Estimated distance remaining to the end of the stroke, in multiples of brush size. This changes dynamically as you draw. + Time elapsed since this specific input was recorded, in seconds. This continues to increase and can drive post-stroke animations. + Angle of the direction of travel, in radians from 0 to 2π. + Angle of the direction of travel, centered around zero, in radians from -π to π. + Absolute acceleration of the stroke input, in multiples of brush size per second squared. + Horizontal component of acceleration, in multiples of brush size per second squared. + Vertical component of acceleration, in multiples of brush size per second squared. + Acceleration in the direction of travel, in multiples of brush size per second squared. + Acceleration perpendicular to the direction of travel, in multiples of brush size per second squared. + Absolute speed of the pointer on the screen, in centimeters per second. + Horizontal velocity of the pointer, in centimeters per second. + Vertical velocity of the pointer, in centimeters per second. + Distance traveled by the pointer on the screen, in centimeters. + Predicted distance to be traveled by the pointer, in centimeters. + Absolute acceleration of the pointer, in centimeters per second squared. + Horizontal acceleration of the pointer, in centimeters per second squared. + Vertical acceleration of the pointer, in centimeters per second squared. + Acceleration of the pointer in the direction of travel, in centimeters per second squared. + Acceleration of the pointer perpendicular to the direction of travel, in centimeters per second squared. + Distance remaining to the end of the stroke, as a fraction of the total stroke length. + Time elapsed since the stroke ended, in seconds. Useful for post-stroke effects. + Unspecified input source. + Elapsed time of the prediction in milliseconds. This is deprecated; use SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS instead. + Time elapsed since input was recorded in milliseconds. This is deprecated; use SOURCE_TIME_SINCE_INPUT_IN_SECONDS instead. + + Scales the brush tip width. If multiple behaviors target this or size multiplier, they combine multiplicatively. The final width is clamped to a maximum of twice the baseline width. + Scales the brush tip height. If multiple behaviors target this or size multiplier, they combine multiplicatively. The final height is clamped to a maximum of twice the baseline height. + A convenience target that scales both width and height of the brush tip simultaneously, maintaining the aspect ratio. + Adds an offset to the brush tip rotation. The final rotation angle is normalized modulo 2π. If multiple behaviors have this target, they stack additively. + Adds an offset to the brush tip corner rounding. The final value is clamped to the range [0, 1]. If multiple behaviors have this target, they stack additively. + Shifts the hue of the brush color. A positive offset shifts around the hue wheel from red towards orange. If multiple behaviors have this target, they stack additively. + Scales the saturation of the brush color. If multiple behaviors have this target, they stack multiplicatively. The final multiplier is clamped to [0, 2]. + Modifies the luminosity of the brush color. An offset of +/-100% corresponds to changing the luminosity by up to +/-100%. + Adds an offset to the brush tip slant. This tilts the shape of the tip. If multiple behaviors have this target, they stack additively. + Adds an offset to the brush tip pinch. This brings the upper corners of the tip closer together. If multiple behaviors have this target, they stack additively. + Scales the opacity of the brush color. If multiple behaviors have this target, they stack multiplicatively. The final multiplier is clamped to [0, 2]. + Adds an offset to the brush tip position along the horizontal axis. The offset is measured in multiples of the brush size. + Adds an offset to the brush tip position along the vertical axis. The offset is measured in multiples of the brush size. + Moves the brush tip forward or backward along the direction of travel. The distance is measured in multiples of the brush size. + Moves the brush tip sideways relative to the direction of travel. The distance is measured in multiples of the brush size. + Unspecified target. + + + Repeats the texture image horizontally and vertically, creating a tiled pattern. This is useful for seamless textures like paper or canvas. + Repeats the texture image while alternating mirror images, ensuring that adjacent edges always match. This helps to avoid visible seams in the pattern. + Clamps the texture to its edges. Points outside the texture take the color of the nearest edge pixel, stretching the border colors outward. + Unspecified texture wrap mode. + + + Specifies the texture size in the same absolute units as the stroke input position. This means the texture scale remains constant regardless of brush size. + Specifies the texture size as a multiple of the brush size. This means the texture scales up or down along with the brush width. + Units for specifying the size of the texture on the stroke. + + + The texture origin is fixed at the origin of the stroke space. This ensures that the texture remains aligned across different strokes. + The texture origin is anchored to the very first input point of the stroke. This means the pattern starts predictably at the beginning of each stroke. + The texture origin is anchored to the last input position of the stroke. This means the texture pattern will move along with the brush as you draw. + Specification of the origin point to use for the texture mapping. + + + The texture repeats as tiles across the stroke mesh. Each copy of the texture has the same size and shape, creating a continuous pattern. + The texture is \'stamped\' onto each individual particle of the stroke. This is intended for use with particle brushes to apply a shape to each dot. + How the texture should be applied and mapped to the stroke geometry. + + + Multiplies the source and destination colors. This results in a darker color, similar to multiplying layers in image editors. + Keeps the destination pixels that overlap with the source pixels, acting as a mask. The source color itself is not drawn. + Keeps the destination pixels that do NOT overlap with the source pixels, effectively erasing parts of the destination. + Draws the source color only where the destination exists, masking it to the existing shape. + Keeps the source pixels only where they overlap with the destination pixels, masking the source to the destination shape. + Draws the source color over the destination color. This is the standard blending mode most commonly used. + Draws the destination color over the source color. The source color is drawn behind the existing content. + Keeps the source color and discards the destination color. This effectively ignores the background or previous layers. + Keeps the destination color and discards the source color. This effectively ignores the current layer. + Keeps the source color only where it does NOT overlap with the destination. This can be used to cut out shapes. + Draws the destination color only where the source exists, masking it to the new shape. + Combines source and destination but clears areas where they intersect. This creates a visual effect where overlapping parts disappear. + How the texture color combines with the paint color. + + The default behavior, which may vary by implementation. It allows the renderer to choose the most efficient method. + Overlapping parts of a single stroke build up opacity and color density, mimicking physical ink buildup like a highlighter or watercolor where the path crosses itself. + The stroke maintains uniform opacity and color even where it crosses over itself, rendering as a flat, continuous shape without darkening at intersections. + How overlapping parts of the same stroke are rendered. + + + Adds a position offset in absolute stroke space. The angle determines the direction (0 is positive X-axis) and magnitude determines the distance in multiples of the brush size. + Adds a position offset relative to the current direction of travel. An angle of zero is forward, allowing you to create trailing or leading effects. + Unspecified polar target. + + + Values outside the specified range are clamped to the minimum or maximum boundary value. This prevents the output from exceeding the desired limits. + Values outside the range repeat by wrapping around to the other side, creating a repeating pattern or cycle. + Values outside the range repeat in a mirrored fashion, bouncing back and forth between the boundaries. This creates a smooth, oscillating effect. + Unspecified out of range behavior. + + + Stylus or touch pressure (Deprecated). + Stylus tilt (Deprecated). + Stylus orientation (Deprecated). + Stylus tilt along X and Y axes (Deprecated). + Optional input property (Deprecated). + + + Multiplies value A by value B. If either value is missing (null), the result is also missing. + Adds value A to value B. If either value is missing (null), the result is also missing. + Returns the smaller of the two values, A and B. If either value is missing, the result is missing. + Returns the larger of the two values, A and B. If either value is missing, the result is missing. + Returns value B only if value A is present (not null). If A is missing, it returns missing, allowing for conditional execution. + Returns value A if it is present. If A is missing, it falls back and returns value B instead. + Returns A if B is missing, or B if A is missing. If both are present or both are missing, it returns missing. + Binary operation to combine two values. + + + Damping or noise variation occurs over time, measured in seconds. This creates time-dependent effects that continue even if you stop moving. + Damping or noise variation occurs over physical distance traveled on the screen, measured in centimeters. This requires screen calibration data to be accurate. + Damping or noise variation occurs over distance traveled, measured in multiples of the brush size. This scales the effect naturally with the brush width. + Domain for measuring damping or noise progress. + + + Linear interpolation (LERP). Uses the first input value as a percentage (0 to 1) to blend between the second value and the third value. + Inverse linear interpolation. Calculates where the first value lies within the range defined by the second and third values, returning a 0 to 1 percentage. + Interpolation function to combine values. + + + Linear easing function. It returns the input value unchanged, creating a direct, proportional response. + Standard \'ease\' function. It starts slowly, accelerates in the middle, and slows down again at the end. + Standard \'ease-in\' function. It starts slowly and accelerates towards the end. + Standard \'ease-out\' function. It starts quickly and decelerates towards the end. + Standard \'ease-in-out\' function. It combines ease-in and ease-out, starting and ending slowly. + Step-start function. The value jumps immediately to the final value at the very beginning of the interval. + Step-end function. The value remains at the initial value until the very end of the interval, then jumps to the final value. + Predefined easing function to shape the response curve. + + + The step function jumps at the start of each interval, meaning the value changes at the beginning of the step. + The step function jumps at the end of each interval, meaning the value changes at the end of the step. + The step function does not jump at either boundary, maintaining a smoother transition between steps. + The step function jumps at both the start and the end of the interval. + Step position behavior for step easing functions. + + + Averages nearby inputs together within a sliding time window. This creates smooth strokes by blending recent inputs. + A simple model that passes through raw inputs mostly unchanged. This is experimental and may result in less smooth strokes. + Selects the model used to smooth raw hardware inputs. + + + Multiplies the opacity of the stroke by a calculated value, allowing for dynamic transparency effects. + Replaces the brush color with a specified color, ignoring the baseline color for this function\'s output. + Selects the type of color function to apply. + + Welcome! + We will walk through the brush graph UI and explain how to use it by building a basic brush from the ground up. We already have the building blocks of a brush here at the beginning: Family (top-level representation of a brush style, similar to a font family), a Coat (layer of ink), a Tip (geometry extruded to create the mesh), and Paint (color and texture). + Test Canvas + Tap to open the test canvas at the bottom, and draw on it. + Test Canvas Options + As we modify the brush, the test canvas will auto-update any strokes drawn here, so it is useful to have something here as we edit the brush. There\'s also options here to configure the canvas\' color, clear it, and change the brush\'s color and size. + Edit Tip + Tap the Tip node to open the inspector view. + Modify Tip Shape + Modify the tip shape parameters. You can either use the slider, or tap the number to manually enter a value. + Live Strokes + Notice how changing the parameters affects the previews on the tip, coat, and family nodes, and also the test strokes in the canvas. + Inspector Features + Notice a few other key features in the inspector: the (?) button (show tooltips), the delete button (delete a node), and the disable button (temporarily deactivate a node). + Exit Inspector + Exit the inspector by clicking the (x) button or clicking on an empty part of the canvas. + Brush Behaviors + In addition to defining a shape, a Tip contains behaviors. In a behavior, a \'Source\' collects data from the input (e.g., pressure, speed), normalizes it, and passes it to a \'Target\' which uses that data to modify a value that affect the appearance of the stroke (e.g., color, size). + Add Behavior + The \'+...\' buttons on nodes will add a default node that produces an output compatible as input for the other node, and connect the two. Begin by adding a new behavior to the tip by clicking \'+ Behavior\'. + Error! + Uh-oh, this causes an error! Click the error icon in the upper right corner to take a closer look. + Notification Pane + There are three types of notifications: Error (brush is invalid), Warning (potential issue with brush), and Debug (a record of certain actions). Here we have one error, caused by target we created not having an input. + Navigate to Nodes with Issues + In most cases, the error or warning message will link to the node causing it. Click the error to navigate directly to the node with the issue. + Add Input + To fix the missing input issue, let\'s add an input to this target. We could click \'+ Input\', but let\'s try another way: click the floating action button (+) and select \'behavior\'. + Change Node Type + This will make a \'Target\' node by default, and automatically open the inspector. Change the Node Type to \'Source\' in the inspector. + Move Node + Move the node to the left of the existing \'Target\' by dragging it. + Connect Nodes + Create a connection between the two nodes by dragging from the output port (gray dot to the right of the Source node, labelled \'Out\') to the input port (gray dot to the left of the Target, labelled \'+ Input\'). + Edit Target + The validation error should now be fixed. You may already notice your stroke changing with this new behavior! Let\'s configure it futher: tap the Target node to open the inspector. + Set Target + Set the \'Target\' to \'hue offset\'. + Target Range Sliders + Notice the \'range start\' and \'range end\' sliders. These control how much this target value will be changed based on the inputs it receives. + Set Target Range + Let\'s give it a wide range so we get a lot of hue variation, say -360 to 360. + Select Source Node + When you\'re done, tap the Source node to open the inspector. + Set Source Type + Set the source to \'distance traveled\'. + Source Range Sliders + Source also has \'range\' sliders. These control how to normalize Source data to a 0 to 1 range, determining what range of Source data to map to the upper and lower range of the Target. + Set Source Range + Set source start to 0 and end to ~30. + Effects of Range + Notice how hue offset is only applied to the beginning of the stroke, where distance traveled is between 0 and ~30. The hue offset between those values follows the range set in the Target node of -360 to 360. + Explain Behavior + Together, these two nodes describe a behavior: map distance traveled in the range given by Source to hue offset in the range set by Target. + Out of Range Behavior + Notice \'out of range behavior\'. This controls what value the Source should pass when the data is outside of the set range. + Clamp + By default, the out of range behavior is \'clamp\': outside of the range, values are clamped to the bounds of the range, 0 or 1. This leads to no hue offset outside the Source range, because our Target range is -360 to 360 (and 360 or -360 hue offset is the same as none). + Mirror and Repeat + Try changing out of range behavior to \'mirror\' or \'repeat\' and notice what changes. + Moving On to a More Complex Behavior + Next we will build a more complicated behavior. Before we proceed, we will configure the Source to best illustrate the impacts of the different nodes. + Set a Narrow Range + Set the range of the source node narrow, with space on each side (say 20 to 80). + Set Clamp + When you\'re done with the range, set the Source node to use \'clamp\'. + Move Node + Let\'s make some space for another node. Move the source node to the left to make the edge longer. + Click Edge + Click the edge between the Source and the Target to open the edge inspector. + Edge Inspector + Like the node inspector, the edge inspector has disable and delete buttons which function similarly. It also lists the two nodes connected by the edge. + Node Navigation + Click either node listed in the edge inspector to navigate directly to it. + Back to Edge Inspector + Reopen the edge inspector for this edge. + Between Two Nodes + Let\'s insert a node inbetween these nodes. Click \'add node between\'. + Edit Node Between + By default, this adds a Response node, but let\'s change the Node Type to a Binary Op. + Binary Op + Binary Ops combine two values according to a function (e.g., sum, product). Notice there is an error since a Binary Op needs at least two values. In brush graph, a Binary Op can have more than two values; this is a UI convenience to make it easy to chain many Binary Ops together. + Add Input to Binary Op + Resolve the error by clicking \'+ Input\' on the Binary Op. + Configure New Node + Click the new Source node added to open it in the inspector. + Other Origins of Values + Values don\'t have to originate from data collected by a Source node; they can be randomly generated by a Noise node, or static from a Constant node. + Change to Constant + Change the Node Type to a Constant node. + Slide Constant Value + Try sliding the constant value around. + Constant Value Effects + Notice how the color changes; we still have the same section with a hue offset affected by the distance traveled, but the color of the stroke outside of that range changes with the constant! This is because we are summing these values together, but the amount of distance traveled only impacts hue offset within the set range, since we are using the clamp out of range behavior. + Change Operation + Let\'s change what the Binary Op is doing. Click to select the Binary Op node. + Select Product + Change the Operation to \'product\'. + Back to Constant + Tap the constant node to open it in the inspector. + Change the Constant + Now that we are multiplying values in the Binary Op, try sliding the constant around and see what happens. + The Constant\'s Effect + Notice that the area before the area affected by the Source is unaffected by any hue offset modifier, no matter how you change the constant. + Multiplying vs. Adding + This is because values are normalized on a range of 0 to 1, so with clamp, everything outside of the Source\'s range is either 0 or 1, depending on which side it is on. On the lower side, that is 0, and 0 * constant = 0 passed to Target, which maps to the lower end of the range for hue offset: -360, which is equivalent to 0, or no hue offset change. On the higher side, it is clamped to 1, and 1 * constant = constant passed to Target, which can then affect hue offset. + Further Exploration + What do you think would happen if the Source\'s out of range behavior was set to mirror or repeat? Feel free to explore this on your own. When you\'re ready to proceed, set the Source\'s out of range behavior back to clamp and click \'Next\'. + Add Another Coat + Let\'s add another coat for a \'border\' effect. Long-press any node other than the Family node to enter \'selection mode\'. + Select Nodes + Tap every node except the Family node to select them, or click \'Select All\' (this ignores the Family node). + Duplicate Nodes + Click \'Duplicate\' to make a copy of all of these nodes. + Move Duplicated Nodes + Drag any of the selected nodes to move them all out of the way so they don\'t overlap the other nodes. Click \'Done\' when you\'re ready to deselect them. + Warning! + Notice a warning appears since the output of all these nodes we copied is unused. + Connect to Family + Draw an edge from the \'Out\' port of the new coat to the \'+ Coat\' port on the Family node. + Modify Second Coat + Open the Tip of the second Coat. + Create Border Effect + Increase scale x and y to make it wider and taller. + Add Color Function + When you\'re done with the Tip, go to the Paint node of the second Coat, and click \'+ Color\' to add a Color Function. + Coat Order Significance + By default this should make a \'Replace Color\' function. You may see the whole stroke change color. If so, this is because of the order of the coats. Coats earlier in the order are drawn first, with later coats drawn on top. + Swap Coat Order + We want to draw the new, larger, recolored coat beneath the smaller one. Swap the order of the coats on the Family node by dragging the handle on \'coat 1\' up to swap with \'coat 0\'. + Change Order by Editing Edges + We could also change the order by \'editing\' the edges. Dragging from any input port with an edge connected allows you to move the edge. Release the edge while editing to delete, or reconnect it to another port to move it there. + Back Color Function + Navigate back to the Color Function inspector by clicking it. + Change Function Type + Instead of a black outline, let\'s try to make the border look a bit more like a shadow. Change the Function Type to \'Opacity Multiplier\'. + Configure Color Function + Let\'s make the border be a fainter version of the inner part of the stroke. Set Opacity Multiplier ~0.4. + Modify the Border Color + We can modify the constant to make the border follow the same pattern as the inner coat, but with different hues. Click the Constant Node on the border to open it in the inspector. + Configure the Constant + Try changing the value of the Constant. + Border Pattern + Notice that the border retains the same pattern in terms of hue offset, though the base colors change. + Cleanup + If we want the constant to be same, so the inner and outer coats have the exact same colors, we don\'t need the duplicated behavior nodes. Hold down on a node to enter select mode. + Select Duplicated Nodes + Tap to select the duplicated nodes making up the behavior for the border coat: Source, Constant, Binary Op, and Target. + Delete Duplicated Nodes + Click \'Delete\' to get rid of them. + Reuse Behavior + Drag a new edge from the \'Out\' port of the Target on the inner Coat to the \'+ Behavior\' port of the Tip on the outer Coat. + Multiple Outputs + Notice that now, the behavior is applied to both Tips on both Coats! The \'Out\' port on nodes can connect with multiple inputs, enabling complex graphs to use fewer nodes and be easier to design. + Tutorial Complete! + That should be enough to get you started. You can find Templates in the menu, which are a great next step to understand how brushes work. Remember to click the (?) buttons for tooltips if you get stuck. Happy designing! + + From + To + Input: %1$s + Edge + Inspector: %1$s + Help + Close Inspector + + + Ease + Ease In + Ease Out + Ease In Out + Step Start + Step End + Cubic(%1$f, %2$f, %3$f, %4$f) + Steps(%1$d, %2$s) + Linear(%1$s) + jump start + jump end + jump both + jump none + + Mapping Mode: %1$s + Size Unit: %1$s + Origin: %1$s + Wrap X: %1$s + Wrap Y: %1$s + Blend Mode: %1$s + + + Window Size (ms) + Upsampling Frequency (Hz) + \ No newline at end of file diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphDataModelTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphDataModelTest.kt new file mode 100644 index 00000000..aacd05d0 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphDataModelTest.kt @@ -0,0 +1,381 @@ +/* + * * 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.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.R +import ink.proto.BrushBehavior as ProtoBrushBehavior +import org.junit.Assert.assertEquals +import org.junit.Test + +class GraphDataModelTest { + + @Test + fun getVisiblePorts_singleInputNodeNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setResponseNode(ProtoBrushBehavior.ResponseNode.getDefaultInstance()) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_singleInputNodeWithConnections_returnsInputAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setResponseNode(ProtoBrushBehavior.ResponseNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("Input") + ) + ) + val sourceNode = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "Input") + val graph = BrushGraph(nodes = listOf(node, sourceNode), edges = listOf(edge)) + val ports = node.getVisiblePorts(graph) + + assertEquals(2, ports.size) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + assertEquals(false, ports[0].isAddPort) + + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[1].label) + assertEquals(true, ports[1].isAddPort) + } + + @Test + fun getVisiblePorts_binaryOpNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().setOperation(ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_binaryOpWithConnections_returnsInputsAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().setOperation(ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + ) + val sourceNode = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "input_0") + val graph = BrushGraph(nodes = listOf(node, sourceNode), edges = listOf(edge)) + val ports = node.getVisiblePorts(graph) + + assertEquals(3, ports.size) + assertEquals("input_0", ports[0].id) + assertEquals(DisplayText.Literal("A"), ports[0].label) + assertEquals("input_1", ports[1].id) + assertEquals(DisplayText.Literal("B"), ports[1].label) + assertEquals("add_input", ports[2].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[2].label) + } + + @Test + fun getVisiblePorts_polarTargetNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_polarTargetWithConnections_returnsInputsAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("angle_0", "mag_0") + ) + ) + val sourceNode1 = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val sourceNode2 = GraphNode(id = "3", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge1 = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "angle_0") + val edge2 = GraphEdge(fromNodeId = "3", toNodeId = "1", toPortId = "mag_0") + val graph = BrushGraph(nodes = listOf(node, sourceNode1, sourceNode2), edges = listOf(edge1, edge2)) + val ports = node.getVisiblePorts(graph) + + assertEquals(3, ports.size) + assertEquals("angle_0", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_angle), ports[0].label) + assertEquals("mag_0", ports[1].id) + assertEquals(DisplayText.Resource(R.string.bg_port_mag), ports[1].label) + assertEquals("add_input", ports[2].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[2].label) + } + + @Test + fun getVisiblePorts_paintNoConnections_returnsAddTextureAndAddColorPorts() { + val node = GraphNode(id = "1", data = NodeData.Paint(paint = ink.proto.BrushPaint.getDefaultInstance())) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(2, ports.size) + assertEquals("add_texture", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[0].label) + assertEquals("add_color", ports[1].id) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[1].label) + } + + @Test + fun getVisiblePorts_paintWithConnections_returnsPortsAndAddPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Paint( + paint = ink.proto.BrushPaint.getDefaultInstance(), + texturePortIds = listOf("texture_0"), + colorPortIds = listOf("color_0") + ) + ) + val textureNode = GraphNode(id = "2", data = NodeData.TextureLayer(layer = ink.proto.BrushPaint.TextureLayer.getDefaultInstance())) + val colorNode = GraphNode(id = "3", data = NodeData.ColorFunction(function = ink.proto.ColorFunction.getDefaultInstance())) + + val edge1 = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "texture_0") + val edge2 = GraphEdge(fromNodeId = "3", toNodeId = "1", toPortId = "color_0") + + val graph = BrushGraph(nodes = listOf(node, textureNode, colorNode), edges = listOf(edge1, edge2)) + val ports = node.getVisiblePorts(graph) + + assertEquals(4, ports.size) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[0].label) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[1].label) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[2].label) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[3].label) + } + + @Test + fun preserveEdgesOnTypeChange_binaryOpToInterpolation_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1", "input_2") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + val sourceId3 = "4" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + val edge3 = GraphEdge(fromNodeId = sourceId3, toNodeId = targetNodeId, toPortId = "input_2") + + val edges = listOf(edge1, edge2, edge3) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(3, finalEdges.size) + + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + val behaviorData = finalNewData as NodeData.Behavior + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + val e3 = finalEdges.find { it.fromNodeId == sourceId3 }!! + assertEquals(behaviorData.inputPortIds[2], e3.toPortId) + + assertEquals(3, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_interpolationToBinaryOp_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build() + ) + + val sourceId1 = "2" + val sourceId2 = "3" + val sourceId3 = "4" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "Value") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "Start") + val edge3 = GraphEdge(fromNodeId = sourceId3, toNodeId = targetNodeId, toPortId = "End") + + val edges = listOf(edge1, edge2, edge3) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(3, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + val e3 = finalEdges.find { it.fromNodeId == sourceId3 }!! + assertEquals(behaviorData.inputPortIds[2], e3.toPortId) + + assertEquals(3, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_binaryOpToPolarTarget_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + + val edges = listOf(edge1, edge2) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(2, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + assertEquals(2, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_toTargetNode_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + + val edges = listOf(edge1, edge2) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setTargetNode(ProtoBrushBehavior.TargetNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(2, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + assertEquals(2, behaviorData.inputPortIds.size) + } + + @Test + fun subtitles_constantNode_formatsWithDotSeparator() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setConstantNode(ProtoBrushBehavior.ConstantNode.newBuilder().setValue(1.5f).build()) + .build() + ) + ) + + val subtitles = node.data.subtitles() + + assertEquals(1, subtitles.size) + val subtitle = subtitles[0] + assert(subtitle is DisplayText.Literal) + assertEquals("1.50", (subtitle as DisplayText.Literal).text) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt new file mode 100644 index 00000000..cfdca02c --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt @@ -0,0 +1,91 @@ +/* + * * 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.R +import ink.proto.BrushBehavior as ProtoBrushBehavior +import org.junit.Assert.assertEquals +import org.junit.Test + +class GraphValidatorTest { + + @Test + fun validateAll_duplicateIssuesWithDifferentArgs_areNotDeduplicated() { + val exc1 = GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Literal("A"))), + nodeId = "1" + ) + val exc2 = GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Literal("B"))), + nodeId = "1" + ) + + val issues = listOf(exc1, exc2, exc1) + val distinctIssues = issues.distinct() + + assertEquals(2, distinctIssues.size) + assertEquals(exc1, distinctIssues[0]) + assertEquals(exc2, distinctIssues[1]) + } + + @Test + fun validateAll_interpolationNodeWithExcessInputs_doesNotThrowIndexOutOfBounds() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("value", "start", "end", "excess_1", "excess_2") + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + + val issues = GraphValidator.validateAll(graph) + + val missingInputIssues = issues.filter { it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_interp_missing_input } + assertEquals(3, missingInputIssues.size) + } + + @Test + fun validateAll_sourceRangeEqual_producesMessageWithoutNestedLists() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(1.0f) + .setSourceValueRangeEnd(1.0f) + .build() + ) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + + val issues = GraphValidator.validateAll(graph) + + val rangeIssues = issues.filter { it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_source_range_equal } + assertEquals(1, rangeIssues.size) + + val displayMessage = rangeIssues[0].displayMessage as DisplayText.Resource + assertEquals(1, displayMessage.args.size) + + val arg = displayMessage.args[0] + assert(arg is DisplayText) + } +}