From 1cdb38739dbf47b549d0c076753e32ac8373c1d3 Mon Sep 17 00:00:00 2001 From: Abe Pazos Date: Sun, 14 Aug 2022 21:17:52 +0200 Subject: [PATCH] Add Rectangle.fit(Rectangle):Matrix44, FitMethod.Fill, FitMethod.None (#253) --- orx-image-fit/README.md | 63 ++++-- orx-image-fit/build.gradle.kts | 10 +- .../src/commonMain/kotlin/ImageFit.kt | 202 ++++++++++-------- .../src/demo/kotlin/DemoImageFit01.kt | 70 ++++++ 4 files changed, 236 insertions(+), 109 deletions(-) create mode 100644 orx-image-fit/src/demo/kotlin/DemoImageFit01.kt diff --git a/orx-image-fit/README.md b/orx-image-fit/README.md index 48d4a5709..b723774fe 100644 --- a/orx-image-fit/README.md +++ b/orx-image-fit/README.md @@ -2,27 +2,44 @@ Draws the given image making sure it fits (`contain`) or it covers (`cover`) the specified area. -Similar to CSS object-fit (https://www.w3schools.com/css/css3_object-fit.asp) +Similar to CSS object-fit (https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) -`orx-image-fit` provides an extension function `imageFit` for `Drawer`. +`orx-image-fit` provides the `Drawer.imageFit` extension function. ## Usage -`imageFit(img: ColorBuffer, x: Double, y: Double, w: Double, h: Double, fitMethod, horizontalPosition:Double, verticalPosition:Double)` +``` +drawer.imageFit( + img: ColorBuffer, + x: Double, y: Double, w: Double, h: Double, + fitMethod: FitMethod, + horizontalPosition: Double, + verticalPosition: Double) +``` -fitMethod - - `contain` - - `cover` - -horizontal values - - left ... right - - `-1.0` ... `1.0` - - vertical values - - top ... bottom - - `-1.0` ... `1.0` - -## Example +or + +``` +drawer.imageFit( + img: ColorBuffer, + bounds: Rectangle, + fitMethod: FitMethod, + horizontalPosition: Double, + verticalPosition: Double) +``` + +- `img`: the image to draw +- `x`, `y`, `w`, `h` or `bounds`: the target area where to draw the image +- `fitMethod`: + - `FitMethod.Contain`: fits `img` in the target area. If the aspect ratio of `img` and `bounds` differ it leaves blank horizontal or vertical margins to avoid deforming the image. + - `FitMethod.Cover`: covers the target area. . If the aspect ratio of `img` and `bounds` differ part of the image will be cropped away. + - `FitMethod.Fill`: deforms the image to exactly match the target area. + - `FitMethod.None`: draws the image on the target area without scaling it. +- `horizontalPosition` and `verticalPosition`: controls which part of the image is visible (`Cover`, `None`) or the alignment of the image (`Contain`). + - `horizontalPosition`: `-1.0` = left, `0.0` = center, `1.0` = right. + - `verticalPosition`: `-1.0` = top, `0.0` = center, `1.0` = bottom. + +## Examples A quick example that fits an image to the window rectangle with a 10 pixel margin. By default `imageFit` uses the cover mode, which fills the target rectangle with an image. @@ -37,4 +54,16 @@ fun main() = application { } } ``` - + +or + +```kotlin +fun main() = application { + program { + val image = loadImage("data/images/pm5544.png") + extend { + drawer.imageFit(image, drawer.bounds.offsetEdges(-10.0)) + } + } +} +``` diff --git a/orx-image-fit/build.gradle.kts b/orx-image-fit/build.gradle.kts index bba3e7b83..10a935d34 100644 --- a/orx-image-fit/build.gradle.kts +++ b/orx-image-fit/build.gradle.kts @@ -1,3 +1,5 @@ +import ScreenshotsHelper.collectScreenshots + plugins { kotlin("multiplatform") kotlin("plugin.serialization") @@ -10,7 +12,8 @@ kotlin { defaultSourceSet { kotlin.srcDir("src/demo") dependencies { - implementation(project(":orx-camera")) + implementation(project(":orx-shapes")) + implementation(project(":orx-image-fit")) implementation(libs.openrndr.application) implementation(libs.openrndr.extensions) runtimeOnly(libs.openrndr.gl3.core) @@ -18,6 +21,9 @@ kotlin { implementation(compilations["main"]!!.output.allOutputs) } } + collectScreenshots { + + } } } testRuns["test"].executionTask.configure { @@ -74,4 +80,4 @@ kotlin { } } } -} \ No newline at end of file +} diff --git a/orx-image-fit/src/commonMain/kotlin/ImageFit.kt b/orx-image-fit/src/commonMain/kotlin/ImageFit.kt index 4ce287b52..bfb8a8133 100644 --- a/orx-image-fit/src/commonMain/kotlin/ImageFit.kt +++ b/orx-image-fit/src/commonMain/kotlin/ImageFit.kt @@ -2,120 +2,142 @@ package org.openrndr.extra.imageFit import org.openrndr.draw.ColorBuffer import org.openrndr.draw.Drawer +import org.openrndr.math.Matrix44 import org.openrndr.math.Vector2 -import org.openrndr.math.map +import org.openrndr.math.transforms.transform import org.openrndr.shape.Rectangle +import kotlin.math.max +import kotlin.math.min - +/** + * Available `object-fit` methods (borrowed from CSS) + */ enum class FitMethod { + /** Cover target area. Crop the source image if needed. */ Cover, - Contain -} - -fun fitRectangle( - src: Rectangle, - dest: Rectangle, - horizontalPosition: Double = 0.0, - verticalPosition: Double = 0.0, - fitMethod: FitMethod = FitMethod.Cover -): Pair { - val sourceWidth = src.width - val sourceHeight = src.height - val targetX: Double - val targetY: Double - var targetWidth: Double - var targetHeight: Double + /** Fit image in target area. Add margins if needed. */ + Contain, - val source: Rectangle - val target: Rectangle + /** Deform source image to match the target area. */ + Fill, - val (x, y) = dest.corner - val width = dest.width - val height = dest.height + /** Maintain original image scale, crop to target area size. */ + None - when (fitMethod) { - FitMethod.Contain -> { - targetWidth = width - targetHeight = height - - if (width <= targetWidth) { - targetWidth = width - targetHeight = (sourceHeight / sourceWidth) * width - } - - if (height <= targetHeight) { - targetHeight = height - targetWidth = (sourceWidth / sourceHeight) * height - } - - val left = x - val right = x + width - targetWidth - val top = y - val bottom = y + height - targetHeight - - targetX = map(-1.0, 1.0, left, right, horizontalPosition) - targetY = map(-1.0, 1.0, top, bottom, verticalPosition) + /** Not implemented */ + // ScaleDown +} - source = Rectangle(0.0, 0.0, sourceWidth, sourceHeight) - target = Rectangle(targetX, targetY, targetWidth, targetHeight) - } +/** + * Transforms [src] and [dest] into a Pair in which one of the + * two rectangles is modified to conform with the [fitMethod]. It uses + * [horizontalPosition] and [verticalPosition] to control positioning / cropping. + */ +fun fitRectangle( + src: Rectangle, + dest: Rectangle, + horizontalPosition: Double = 0.0, + verticalPosition: Double = 0.0, + fitMethod: FitMethod = FitMethod.Cover +): Pair { + val positionNorm = Vector2(horizontalPosition, verticalPosition) * 0.5 + 0.5 + val (scaleX, scaleY) = dest.dimensions / src.dimensions + return when (fitMethod) { FitMethod.Cover -> { - targetWidth = sourceWidth - targetHeight = sourceHeight - - if (sourceWidth <= targetWidth) { - targetWidth = sourceWidth - targetHeight = (height / width) * sourceWidth - } - - if (sourceHeight <= targetHeight) { - targetHeight = sourceHeight - targetWidth = (width / height) * sourceHeight - } - - val left = 0.0 - val right = sourceWidth - targetWidth - val top = 0.0 - val bottom = sourceHeight - targetHeight + val actualDimensions = dest.dimensions / max(scaleX, scaleY) + val actualSrc = Rectangle( + src.corner + (src.dimensions - actualDimensions) * positionNorm, + actualDimensions.x, actualDimensions.y + ) + Pair(actualSrc, dest) + } - targetX = map(-1.0, 1.0, left, right, horizontalPosition) - targetY = map(-1.0, 1.0, top, bottom, verticalPosition) + FitMethod.Contain -> { + val actualDimensions = src.dimensions * min(scaleX, scaleY) + val actualDest = Rectangle( + dest.corner + (dest.dimensions - actualDimensions) * positionNorm, + actualDimensions.x, actualDimensions.y + ) + Pair(src, actualDest) + } - source = Rectangle(targetX, targetY, targetWidth, targetHeight) - target = Rectangle(x, y, width, height) + FitMethod.Fill -> Pair(src, dest) + FitMethod.None -> { + val actualSrc = Rectangle( + src.corner + (src.dimensions - dest.dimensions) * positionNorm, + dest.width, dest.height + ) + Pair(actualSrc, dest) } } - - return Pair(source, target) } -fun Drawer.imageFit( - img: ColorBuffer, - x: Double = 0.0, - y: Double = 0.0, - width: Double = img.width.toDouble(), - height: Double = img.height.toDouble(), - horizontalPosition: Double = 0.0, - verticalPosition: Double = 0.0, - fitMethod: FitMethod = FitMethod.Cover -): Pair { +/** + * Helper function that calls [fitRectangle] and returns a [Matrix44] instead + * of a `Pair`. The returned matrix can be used to draw + * scaled `Shape` or `ShapeContour` objects. + * + * Example scaling and centering a collection of ShapeContours inside + * `drawer.bounds` leaving a margin of 50 pixels: + * + * val src = shapeContours.map { it.bounds }.bounds + * val dest = drawer.bounds.offsetEdges(-50.0) + * val mat = src.fit(dest, fitMethod = FitMethod.Contain) + * drawer.view *= mat + * drawer.contours(shapeContours) + */ +fun Rectangle.fit( + dest: Rectangle, + horizontalPosition: Double = 0.0, + verticalPosition: Double = 0.0, + fitMethod: FitMethod = FitMethod.Cover +): Matrix44 { val (source, target) = fitRectangle( - img.bounds, - Rectangle(x, y, width, height), - horizontalPosition, - verticalPosition, - fitMethod + this, + dest, + horizontalPosition, + verticalPosition, + fitMethod ) - - image(img, source, target) - return Pair(source, target) + return transform { + translate(target.corner) + scale((target.dimensions / source.dimensions).vector3(z = 1.0)) + translate(-source.corner) + } } +/** + * Draws [img] into the bounding box defined by [x], [y], [width] and [height] + * using the specified [fitMethod] + * and aligned or cropped using [horizontalPosition] and [verticalPosition]. + */ +fun Drawer.imageFit( + img: ColorBuffer, + x: Double = 0.0, + y: Double = 0.0, + width: Double = img.width.toDouble(), + height: Double = img.height.toDouble(), + horizontalPosition: Double = 0.0, + verticalPosition: Double = 0.0, + fitMethod: FitMethod = FitMethod.Cover +) = imageFit( + img, + Rectangle(x, y, width, height), + horizontalPosition, + verticalPosition, + fitMethod +) + +/** + * Draws [img] into the bounding box defined by [bounds] + * using the specified [fitMethod] + * and aligned or cropped using [horizontalPosition] and [verticalPosition]. + */ fun Drawer.imageFit( img: ColorBuffer, - bounds: Rectangle = Rectangle(Vector2.ZERO, img.width * 1.0, img.height * 1.0), + bounds: Rectangle = img.bounds, horizontalPosition: Double = 0.0, verticalPosition: Double = 0.0, fitMethod: FitMethod = FitMethod.Cover diff --git a/orx-image-fit/src/demo/kotlin/DemoImageFit01.kt b/orx-image-fit/src/demo/kotlin/DemoImageFit01.kt new file mode 100644 index 000000000..0e469b1d6 --- /dev/null +++ b/orx-image-fit/src/demo/kotlin/DemoImageFit01.kt @@ -0,0 +1,70 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.ColorBuffer +import org.openrndr.draw.isolatedWithTarget +import org.openrndr.draw.loadFont +import org.openrndr.draw.renderTarget +import org.openrndr.extra.imageFit.FitMethod +import org.openrndr.extra.imageFit.imageFit +import org.openrndr.extra.shapes.grid + +/** + * Tests `drawer.imageFit()` with all FitMethods for portrait and landscape images. + */ +fun main() = application { + configure { + width = 1600 + height = 900 + } + + program { + val font = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 18.0) + + // Create a test image with circles + fun makeImage(cols: Int, rows: Int, side: Int = 400): ColorBuffer { + val rt = renderTarget(cols * side, rows * side) { + colorBuffer() + } + drawer.isolatedWithTarget(rt) { + clear(ColorRGBa.WHITE) + stroke = null + ortho(rt) + bounds.grid(cols, rows).flatten().forEachIndexed { i, it -> + fill = if (i % 2 == 0) ColorRGBa.PINK else ColorRGBa.GRAY + circle(it.center, side / 2.0) + } + } + return rt.colorBuffer(0) + } + + val layouts = mapOf( + "portrait" to makeImage(1, 2), + "landscape" to makeImage(2, 1) + ) + val fitMethods = FitMethod.values() + + val grid = drawer.bounds.grid(fitMethods.size, layouts.size, 30.0, 30.0, 30.0, 30.0) + + extend { + drawer.fontMap = font + drawer.stroke = null + fitMethods.forEachIndexed { y, fitMethod -> + layouts.entries.forEachIndexed { x, (layoutName, img) -> + val cell = grid[x][y] + // In each grid cell draw 9 fitted images combining + // [left, center, right] and [top, center, bottom] alignment + val subgrid = cell.grid(3, 3, 0.0, 0.0, 4.0, 4.0) + subgrid.forEachIndexed { yy, rects -> + rects.forEachIndexed { xx, rect -> + drawer.fill = ColorRGBa.WHITE.shade(0.25) + drawer.rectangle(rect) + drawer.imageFit(img, rect, xx - 1.0, yy - 1.0, fitMethod) + } + } + drawer.fill = ColorRGBa.WHITE + drawer.text("${fitMethod.name}, $layoutName", cell.position(0.0, 1.038).toInt().vector2) + } + } + } + } +} \ No newline at end of file