From 26860970c338c96478759bff5e0c9c6becd2fead Mon Sep 17 00:00:00 2001 From: T8RIN Date: Wed, 8 May 2024 21:47:51 +0300 Subject: [PATCH] Try to add flood fill (works ugly and slow) --- .../resources/src/main/res/values/strings.xml | 2 + .../draw/data/AndroidImageDrawApplier.kt | 22 +- .../feature/draw/domain/DrawPathMode.kt | 13 +- .../feature/draw/presentation/DrawScreen.kt | 4 +- .../presentation/components/BitmapDrawer.kt | 193 ++++++++++-------- .../components/DrawPathModeSelector.kt | 4 + .../components/utils/DrawUtils.kt | 34 +-- .../components/utils/PathHelper.kt | 2 + .../filters/data/AndroidFilterMaskApplier.kt | 15 +- .../components/PathPaintPreview.kt | 14 +- .../presentation/components/DrawEditOption.kt | 4 +- gradle/libs.versions.toml | 2 +- 12 files changed, 154 insertions(+), 155 deletions(-) diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index aec446423..66e3c5bd3 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -1082,4 +1082,6 @@ Draw star which will be regular instead of free form Antialias Enables antialiasing to prevent sharp edges + Fills the area with given color + Flood Fill \ No newline at end of file diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/data/AndroidImageDrawApplier.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/data/AndroidImageDrawApplier.kt index 0d3f0e53d..422731aee 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/data/AndroidImageDrawApplier.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/data/AndroidImageDrawApplier.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.core.content.res.ResourcesCompat import androidx.exifinterface.media.ExifInterface import dagger.hilt.android.qualifiers.ApplicationContext +import ru.tech.imageresizershrinker.core.domain.dispatchers.DispatchersHolder import ru.tech.imageresizershrinker.core.domain.image.ImageGetter import ru.tech.imageresizershrinker.core.domain.image.ImageTransformer import ru.tech.imageresizershrinker.core.domain.model.IntegerSize @@ -59,8 +60,9 @@ internal class AndroidImageDrawApplier @Inject constructor( @ApplicationContext private val context: Context, private val imageTransformer: ImageTransformer, private val imageGetter: ImageGetter, - private val filterProvider: FilterProvider -) : ImageDrawApplier { + private val filterProvider: FilterProvider, + private val dispatchersHolder: DispatchersHolder +) : ImageDrawApplier, DispatchersHolder by dispatchersHolder { override suspend fun applyDrawToImage( drawBehavior: DrawBehavior, @@ -112,21 +114,9 @@ internal class AndroidImageDrawApplier @Inject constructor( currentSize = canvasSize, oldSize = size ) - val isRect = listOf( - DrawPathMode.OutlinedRect, - DrawPathMode.OutlinedOval, - DrawPathMode.Rect, - DrawPathMode.Oval - ).any { pathMode::class.isInstance(it) } + val isRect = pathMode.isRect - val isFilled = listOf( - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso, - DrawPathMode.Triangle, - DrawPathMode.Polygon(), - DrawPathMode.Star() - ).any { pathMode::class.isInstance(it) } + val isFilled = !pathMode.isStroke if (drawMode is DrawMode.PathEffect && !isErasing) { val shaderSource = imageTransformer.transform( diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/domain/DrawPathMode.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/domain/DrawPathMode.kt index 3624ab8f5..4d97d63b7 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/domain/DrawPathMode.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/domain/DrawPathMode.kt @@ -59,15 +59,26 @@ sealed class DrawPathMode(open val ordinal: Int) { val isRegular: Boolean = false ) : DrawPathMode(15) + data object FloodFill : DrawPathMode(16) + val isStroke: Boolean - get() = listOf(Lasso, Rect, Oval, Triangle, Polygon(), Star()).all { + get() = listOf(Lasso, Rect, Oval, Triangle, Polygon(), Star(), FloodFill).all { !it::class.isInstance(this) } + val isRect: Boolean + get() = listOf( + OutlinedRect, + OutlinedOval, + Rect, + Oval + ).any { it::class.isInstance(this) } + companion object { val entries by lazy { listOf( Free, + FloodFill, Line, PointingArrow, DoublePointingArrow, diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawScreen.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawScreen.kt index e37187f63..b767d4347 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawScreen.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/DrawScreen.kt @@ -342,7 +342,7 @@ fun DrawScreen( } ) AnimatedVisibility( - visible = drawPathMode.isStroke, + visible = drawPathMode.isStroke || drawPathMode is DrawPathMode.FloodFill, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { @@ -354,6 +354,8 @@ fun DrawScreen( ), title = if (drawMode is DrawMode.Text) { stringResource(R.string.font_size) + } else if (drawPathMode is DrawPathMode.FloodFill) { + stringResource(R.string.tolerance) } else stringResource(R.string.line_width), valueRange = if (drawMode is DrawMode.Image) { 10f..120f diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/BitmapDrawer.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/BitmapDrawer.kt index ec94eae6f..7afed5c78 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/BitmapDrawer.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/BitmapDrawer.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.smarttoolfactory.gesture.MotionEvent import com.smarttoolfactory.gesture.pointerMotionEvents +import jp.co.cyberagent.android.gpuimage.GPUImageNativeLibrary import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.ZoomState import net.engawapg.lib.zoomable.ZoomableDefaults.defaultZoomOnDoubleTap @@ -320,7 +321,9 @@ fun BitmapDrawer( MotionEvent.Down -> { if (currentDrawPosition.isSpecified) { onDrawStart() - drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y) + if (drawPathMode !is DrawPathMode.FloodFill) { + drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y) + } previousDrawPosition = currentDrawPosition pathWithoutTransformations = drawPath.copy() } else { @@ -332,105 +335,135 @@ fun BitmapDrawer( } MotionEvent.Move -> { - drawHelper.drawPath( - onDrawFreeArrow = { - if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) { - drawPath = Path().apply { - moveTo( + if (drawPathMode !is DrawPathMode.FloodFill) { + drawHelper.drawPath( + onDrawFreeArrow = { + if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) { + drawPath = Path().apply { + moveTo( + currentDrawPosition.x, + currentDrawPosition.y + ) + } + pathWithoutTransformations = drawPath.copy() + previousDrawPosition = currentDrawPosition + } + if (previousDrawPosition.isSpecified && currentDrawPosition.isSpecified) { + drawPath = pathWithoutTransformations + drawPath.quadraticTo( + previousDrawPosition.x, + previousDrawPosition.y, + (previousDrawPosition.x + currentDrawPosition.x) / 2, + (previousDrawPosition.y + currentDrawPosition.y) / 2 + ) + previousDrawPosition = currentDrawPosition + + pathWithoutTransformations = drawPath.copy() + + drawArrowsIfNeeded(drawPath) + } + }, + onBaseDraw = { + if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) { + drawPath.moveTo( currentDrawPosition.x, currentDrawPosition.y ) + previousDrawPosition = currentDrawPosition + } + + if (currentDrawPosition.isSpecified && previousDrawPosition.isSpecified) { + drawPath.quadraticTo( + previousDrawPosition.x, + previousDrawPosition.y, + (previousDrawPosition.x + currentDrawPosition.x) / 2, + (previousDrawPosition.y + currentDrawPosition.y) / 2 + ) } - pathWithoutTransformations = drawPath.copy() previousDrawPosition = currentDrawPosition } - if (previousDrawPosition.isSpecified && currentDrawPosition.isSpecified) { - drawPath = pathWithoutTransformations - drawPath.quadraticTo( - previousDrawPosition.x, - previousDrawPosition.y, - (previousDrawPosition.x + currentDrawPosition.x) / 2, - (previousDrawPosition.y + currentDrawPosition.y) / 2 - ) - previousDrawPosition = currentDrawPosition - - pathWithoutTransformations = drawPath.copy() - - drawArrowsIfNeeded(drawPath) - } - }, - onBaseDraw = { - if (previousDrawPosition.isUnspecified && currentDrawPosition.isSpecified) { - drawPath.moveTo(currentDrawPosition.x, currentDrawPosition.y) - previousDrawPosition = currentDrawPosition - } - - if (currentDrawPosition.isSpecified && previousDrawPosition.isSpecified) { - drawPath.quadraticTo( - previousDrawPosition.x, - previousDrawPosition.y, - (previousDrawPosition.x + currentDrawPosition.x) / 2, - (previousDrawPosition.y + currentDrawPosition.y) / 2 - ) - } - previousDrawPosition = currentDrawPosition - } - ) + ) + } motionEvent = MotionEvent.Idle } MotionEvent.Up -> { if (currentDrawPosition.isSpecified && drawDownPosition.isSpecified) { - drawHelper.drawPath( - onDrawFreeArrow = { - drawPath = pathWithoutTransformations - PathMeasure().apply { - setPath(drawPath, false) - }.let { - it.getPosition(it.length) - }.let { lastPoint -> - if (!lastPoint.isSpecified) { - drawPath.moveTo( + if (drawPathMode !is DrawPathMode.FloodFill) { + drawHelper.drawPath( + onDrawFreeArrow = { + drawPath = pathWithoutTransformations + PathMeasure().apply { + setPath(drawPath, false) + }.let { + it.getPosition(it.length) + }.let { lastPoint -> + if (!lastPoint.isSpecified) { + drawPath.moveTo( + currentDrawPosition.x, + currentDrawPosition.y + ) + } + drawPath.lineTo( currentDrawPosition.x, currentDrawPosition.y ) } - drawPath.lineTo( - currentDrawPosition.x, - currentDrawPosition.y - ) - } - drawArrowsIfNeeded(drawPath) - }, - onBaseDraw = { - PathMeasure().apply { - setPath(drawPath, false) - }.let { - it.getPosition(it.length) - }.takeOrElse { currentDrawPosition }.let { lastPoint -> - drawPath.moveTo(lastPoint.x, lastPoint.y) - drawPath.lineTo( - currentDrawPosition.x, - currentDrawPosition.y + drawArrowsIfNeeded(drawPath) + }, + onBaseDraw = { + PathMeasure().apply { + setPath(drawPath, false) + }.let { + it.getPosition(it.length) + }.takeOrElse { currentDrawPosition }.let { lastPoint -> + drawPath.moveTo(lastPoint.x, lastPoint.y) + drawPath.lineTo( + currentDrawPosition.x, + currentDrawPosition.y + ) + } + } + ) + onAddPath( + UiPathPaint( + path = drawPath, + strokeWidth = strokeWidth, + brushSoftness = brushSoftness, + drawColor = drawColor, + isErasing = isEraserOn, + drawMode = drawMode, + canvasSize = canvasSize, + drawPathMode = drawPathMode + ) + ) + } else { + LaunchedEffect(drawDownPosition) { + GPUImageNativeLibrary.floodFill( + srcBitmap = drawImageBitmap.overlay(drawBitmap) + .asAndroidBitmap(), + startX = drawDownPosition.x.toInt(), + startY = drawDownPosition.y.toInt(), + tolerance = strokeWidth.value, + fillColor = drawColor.toArgb() + )?.asComposePath()?.let { path -> + onAddPath( + UiPathPaint( + path = path, + strokeWidth = strokeWidth, + brushSoftness = brushSoftness, + drawColor = drawColor, + isErasing = isEraserOn, + drawMode = drawMode, + canvasSize = canvasSize, + drawPathMode = drawPathMode + ) ) } } - ) - - onAddPath( - UiPathPaint( - path = drawPath, - strokeWidth = strokeWidth, - brushSoftness = brushSoftness, - drawColor = drawColor, - isErasing = isEraserOn, - drawMode = drawMode, - canvasSize = canvasSize, - drawPathMode = drawPathMode - ) - ) + } } currentDrawPosition = Offset.Unspecified diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/DrawPathModeSelector.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/DrawPathModeSelector.kt index 29f6673e2..792b05cfb 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/DrawPathModeSelector.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/DrawPathModeSelector.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.FormatColorFill import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.StarOutline @@ -486,6 +487,7 @@ private fun DrawPathMode.getSubtitle(): Int = when (this) { is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon_sub is DrawPathMode.OutlinedStar -> R.string.outlined_star_sub is DrawPathMode.Star -> R.string.star_sub + DrawPathMode.FloodFill -> R.string.flood_fill_sub } private fun DrawPathMode.getTitle(): Int = when (this) { @@ -506,6 +508,7 @@ private fun DrawPathMode.getTitle(): Int = when (this) { is DrawPathMode.OutlinedPolygon -> R.string.outlined_polygon is DrawPathMode.OutlinedStar -> R.string.outlined_star is DrawPathMode.Star -> R.string.star + DrawPathMode.FloodFill -> R.string.flood_fill } private fun DrawPathMode.getIcon(): ImageVector = when (this) { @@ -526,4 +529,5 @@ private fun DrawPathMode.getIcon(): ImageVector = when (this) { is DrawPathMode.OutlinedPolygon -> Icons.Outlined.Polygon is DrawPathMode.OutlinedStar -> Icons.Rounded.StarOutline is DrawPathMode.Star -> Icons.Rounded.Star + is DrawPathMode.FloodFill -> Icons.Rounded.FormatColorFill } \ No newline at end of file diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/DrawUtils.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/DrawUtils.kt index 60a3917ac..7675df83a 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/DrawUtils.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/DrawUtils.kt @@ -107,22 +107,9 @@ fun rememberPaint( context ) { derivedStateOf { - val isRect = listOf( - DrawPathMode.OutlinedRect, - DrawPathMode.OutlinedOval, - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso - ).any { drawPathMode::class.isInstance(it) } + val isRect = drawPathMode.isRect - val isFilled = listOf( - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso, - DrawPathMode.Triangle, - DrawPathMode.Polygon(), - DrawPathMode.Star() - ).any { drawPathMode::class.isInstance(it) } + val isFilled = !drawPathMode.isStroke Paint().apply { blendMode = if (!isEraserOn) blendMode else BlendMode.Clear @@ -185,22 +172,9 @@ fun pathEffectPaint( drawPathMode: DrawPathMode, canvasSize: IntegerSize, ): NativePaint { - val isRect = listOf( - DrawPathMode.OutlinedRect, - DrawPathMode.OutlinedOval, - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso - ).any { drawPathMode::class.isInstance(it) } + val isRect = drawPathMode.isRect - val isFilled = listOf( - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso, - DrawPathMode.Triangle, - DrawPathMode.Polygon(), - DrawPathMode.Star() - ).any { drawPathMode::class.isInstance(it) } + val isFilled = !drawPathMode.isStroke return Paint().apply { if (isFilled) { diff --git a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/PathHelper.kt b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/PathHelper.kt index 6adc8dc16..9309c47f5 100644 --- a/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/PathHelper.kt +++ b/feature/draw/src/main/java/ru/tech/imageresizershrinker/feature/draw/presentation/components/utils/PathHelper.kt @@ -331,6 +331,8 @@ data class PathHelper( DrawPathMode.Free, DrawPathMode.Lasso -> onBaseDraw() + + DrawPathMode.FloodFill -> Unit } } else onBaseDraw() diff --git a/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/data/AndroidFilterMaskApplier.kt b/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/data/AndroidFilterMaskApplier.kt index 3079cbdd0..6d78bbdcc 100644 --- a/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/data/AndroidFilterMaskApplier.kt +++ b/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/data/AndroidFilterMaskApplier.kt @@ -37,7 +37,6 @@ import ru.tech.imageresizershrinker.core.domain.image.ImageTransformer import ru.tech.imageresizershrinker.core.domain.model.IntegerSize import ru.tech.imageresizershrinker.core.filters.domain.FilterProvider import ru.tech.imageresizershrinker.core.filters.domain.model.Filter -import ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode import ru.tech.imageresizershrinker.feature.draw.domain.PathPaint import ru.tech.imageresizershrinker.feature.filters.domain.FilterMask import ru.tech.imageresizershrinker.feature.filters.domain.FilterMaskApplier @@ -97,19 +96,9 @@ internal class AndroidFilterMaskApplier @Inject constructor( oldSize = pathPaint.canvasSize ) val pathMode = pathPaint.drawPathMode - val isRect = listOf( - DrawPathMode.OutlinedRect, - DrawPathMode.OutlinedOval, - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso - ).any { pathMode::class.isInstance(it) } + val isRect = pathMode.isRect - val isFilled = listOf( - DrawPathMode.Rect, - DrawPathMode.Oval, - DrawPathMode.Lasso - ).any { pathMode::class.isInstance(it) } + val isFilled = !pathMode.isStroke drawPath( path, diff --git a/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/presentation/components/PathPaintPreview.kt b/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/presentation/components/PathPaintPreview.kt index b593c749e..b32ed39c0 100644 --- a/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/presentation/components/PathPaintPreview.kt +++ b/feature/filters/src/main/java/ru/tech/imageresizershrinker/feature/filters/presentation/components/PathPaintPreview.kt @@ -84,19 +84,9 @@ fun PathPaintPreview( .strokeWidth .toPx(currentSize) - val isRect = listOf( - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.OutlinedRect, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.OutlinedOval, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Rect, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Oval, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Lasso - ).any { pathPaint.drawPathMode::class.isInstance(it) } + val isRect = pathPaint.drawPathMode.isRect - val isFilled = listOf( - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Rect, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Oval, - ru.tech.imageresizershrinker.feature.draw.domain.DrawPathMode.Lasso - ).any { pathPaint.drawPathMode::class.isInstance(it) } + val isFilled = !pathPaint.drawPathMode.isStroke canvas.drawPath( pathPaint.path diff --git a/feature/single-edit/src/main/java/ru/tech/imageresizershrinker/feature/single_edit/presentation/components/DrawEditOption.kt b/feature/single-edit/src/main/java/ru/tech/imageresizershrinker/feature/single_edit/presentation/components/DrawEditOption.kt index 250d782f7..7004650ec 100644 --- a/feature/single-edit/src/main/java/ru/tech/imageresizershrinker/feature/single_edit/presentation/components/DrawEditOption.kt +++ b/feature/single-edit/src/main/java/ru/tech/imageresizershrinker/feature/single_edit/presentation/components/DrawEditOption.kt @@ -226,7 +226,7 @@ fun DrawEditOption( } ) AnimatedVisibility( - visible = drawPathMode.isStroke, + visible = drawPathMode.isStroke || drawPathMode is DrawPathMode.FloodFill, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { @@ -238,6 +238,8 @@ fun DrawEditOption( ), title = if (drawMode is DrawMode.Text) { stringResource(R.string.font_size) + } else if (drawPathMode is DrawPathMode.FloodFill) { + stringResource(R.string.tolerance) } else stringResource(R.string.line_width), valueRange = if (drawMode is DrawMode.Image) { 10f..120f diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cadd7a67..fbaaa37ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ versionCode = "138" jvmTarget = "17" compose-compiler = "1.5.13" -imageToolboxLibs = "1.4.8" +imageToolboxLibs = "1.5.0" avifCoderCoil = "1.7.5" avifCoder = "1.7.5"