From 78ddb738505031fdebdc7d51787294fce938acd2 Mon Sep 17 00:00:00 2001 From: Edwin Jakobs Date: Thu, 20 Feb 2025 19:54:52 +0100 Subject: [PATCH] [orx-text-on-contour] Add support for drawing text along contours --- orx-text-on-contour/README.md | 10 + orx-text-on-contour/build.gradle.kts | 22 ++ .../src/commonMain/kotlin/TextOnContour.kt | 271 ++++++++++++++++++ .../src/jvmDemo/kotlin/DemoTextOnContour01.kt | 27 ++ .../src/jvmDemo/kotlin/DemoTextWriter01.kt | 32 ++- settings.gradle.kts | 1 + 6 files changed, 349 insertions(+), 14 deletions(-) create mode 100644 orx-text-on-contour/README.md create mode 100644 orx-text-on-contour/build.gradle.kts create mode 100644 orx-text-on-contour/src/commonMain/kotlin/TextOnContour.kt create mode 100644 orx-text-on-contour/src/jvmDemo/kotlin/DemoTextOnContour01.kt diff --git a/orx-text-on-contour/README.md b/orx-text-on-contour/README.md new file mode 100644 index 00000000..5fb0597a --- /dev/null +++ b/orx-text-on-contour/README.md @@ -0,0 +1,10 @@ +# orx-text-on-contour + +Writing texts on contours. + + +## Demos +### DemoTextWriter01 +[source code](src/jvmDemo/kotlin/DemoTextWriter01.kt) + +![DemoTextWriter01Kt](https://raw.githubusercontent.com/openrndr/orx/media/orx-text-writer/images/DemoTextWriter01Kt.png) diff --git a/orx-text-on-contour/build.gradle.kts b/orx-text-on-contour/build.gradle.kts new file mode 100644 index 00000000..1438ad94 --- /dev/null +++ b/orx-text-on-contour/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + org.openrndr.extra.convention.`kotlin-multiplatform` +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.openrndr.shape) + implementation(libs.openrndr.draw) + implementation(libs.openrndr.application) + implementation(project(":orx-shapes")) + } + } + + val jvmDemo by getting { + dependencies { + implementation(project(":orx-text-on-contour")) + } + } + } +} \ No newline at end of file diff --git a/orx-text-on-contour/src/commonMain/kotlin/TextOnContour.kt b/orx-text-on-contour/src/commonMain/kotlin/TextOnContour.kt new file mode 100644 index 00000000..32a56dd5 --- /dev/null +++ b/orx-text-on-contour/src/commonMain/kotlin/TextOnContour.kt @@ -0,0 +1,271 @@ +package org.openrndr.extra.textoncontour + +import org.openrndr.draw.* +import org.openrndr.extra.shapes.rectify.RectifiedContour +import org.openrndr.internal.Driver +import org.openrndr.internal.GlyphRectangle +import org.openrndr.math.Matrix44 +import org.openrndr.math.Vector2 +import org.openrndr.math.transforms.buildTransform +import kotlin.math.round + +private fun Vector2.transform(m: Matrix44): Vector2 { + return (m * this.xy01).xy +} + +private class OnContourImageMapDrawer { + var lastPos = Vector2.ZERO + + private val shaderManager: ShadeStyleManager = ShadeStyleManager.fromGenerators( + "font-image-map", + vsGenerator = Driver.instance.shaderGenerators::fontImageMapVertexShader, + fsGenerator = Driver.instance.shaderGenerators::fontImageMapFragmentShader + ) + + private val maxQuads = 20_000 + + private val vertices = VertexBuffer.createDynamic(VertexFormat().apply { + textureCoordinate(2) + attribute("bounds", VertexElementType.VECTOR4_FLOAT32) + position(3) + attribute("instance", VertexElementType.FLOAT32) + }, 6 * maxQuads) + + private var quadCount = 0 + + fun drawTextOnContour( + contour: RectifiedContour, + context: DrawContext, + drawStyle: DrawStyle, + text: String, + offsetX: Double = 0.0, + offsetY: Double = 0.0, + tracking: Double = 0.0, + scale: Double = 1.0 + ) = drawTextsOnContours( + contour, + context, + drawStyle, + listOf(text), + listOf(Vector2(offsetX, offsetY)), + tracking, + scale + ) + + class SetResult(val cursorT: List, val glyphRectangles: List>) + + fun drawTextsOnContours( + contour: RectifiedContour, + context: DrawContext, + drawStyle: DrawStyle, + texts: List, + positions: List, + tracking: Double = 0.0, + scale: Double = 1.0 + ): SetResult { + val fontMap = drawStyle.fontMap as? FontImageMap + val cursorTs = mutableListOf() + + if (fontMap != null) { + var instance = 0 + + val textAndPositionPairs = texts.zip(positions) + for ((text, position) in textAndPositionPairs) { + var cursorX = position.x + val cursorY = 0.0 + + val bw = vertices.shadow.writer() + bw.position = vertices.vertexFormat.size * quadCount * 6 + + var lastChar: Char? = null + text.forEach { + val lc = lastChar + if (drawStyle.kerning == KernMode.METRIC) { + cursorX += if (lc != null) fontMap.kerning(lc, it) else 0.0 + } + val metrics = fontMap.glyphMetrics[it] ?: fontMap.glyphMetrics.getValue(' ') + val (dx, _) = insertCharacter( + contour, + fontMap, + bw, + it, + cursorX, + position.y + cursorY, + instance, + drawStyle.textSetting, + scale + ) + cursorX += metrics.advanceWidth + dx + tracking + lastChar = it + } + cursorTs.add(cursorX) + instance++ + } + flush(context, drawStyle) + } + return SetResult(cursorTs, emptyList()) + } + + var queuedInstances = 0 + + fun flush(context: DrawContext, drawStyle: DrawStyle) { + if (quadCount > 0) { + vertices.shadow.uploadElements(0, quadCount * 6) + val shader = shaderManager.shader(drawStyle.shadeStyle, vertices.vertexFormat) + shader.begin() + context.applyToShader(shader) + + Driver.instance.setState(drawStyle) + drawStyle.applyToShader(shader) + (drawStyle.fontMap as FontImageMap).texture.bind(0) + Driver.instance.drawVertexBuffer( + shader, + listOf(vertices), + DrawPrimitive.TRIANGLES, + 0, + quadCount * 6, + verticesPerPatch = 0 + ) + shader.end() + quadCount = 0 + } + queuedInstances = 0 + } + + private fun insertCharacter( + contour: RectifiedContour, + fontMap: FontImageMap, + bw: BufferWriter, + character: Char, + cx: Double, + cy: Double, + instance: Int, + textSetting: TextSettingMode, + scale: Double + ): Pair { + + val rectangle = fontMap.map[character] ?: fontMap.map[' '] + val targetContentScale = RenderTarget.active.contentScale + + val x = if (textSetting == TextSettingMode.PIXEL) round(cx * targetContentScale) / targetContentScale else cx + + val metrics = + fontMap.glyphMetrics[character] ?: fontMap.glyphMetrics[' '] ?: error("glyph or space substitute not found") + + val glyphRectangle = + if (rectangle != null) { + val pad = 2.0f + val ushift = 0.0f + val xshift = (metrics.xBitmapShift / fontMap.contentScale).toFloat() + val yshift = (metrics.yBitmapShift / fontMap.contentScale).toFloat() + + val u0 = (rectangle.x.toFloat() - pad) / fontMap.texture.effectiveWidth + ushift + val u1 = + (rectangle.x.toFloat() + rectangle.width.toFloat() + pad) / fontMap.texture.effectiveWidth + ushift + val v0 = (rectangle.y.toFloat() - pad) / fontMap.texture.effectiveHeight + val v1 = v0 + (pad * 2 + rectangle.height.toFloat()) / fontMap.texture.effectiveHeight + + + val x0 = -pad / fontMap.contentScale.toFloat() + xshift + val x1 = + (rectangle.width.toFloat() / fontMap.contentScale.toFloat()) + pad / fontMap.contentScale.toFloat() + xshift + + + val t = (x + (x0 + x1) / 2.0) / (contour.contour.length / scale) + + if (t >= 1.0) { + null + } else { + val pose = contour.pose(t) + + val y0 = -pad / fontMap.contentScale.toFloat() + yshift + val y1 = + rectangle.height.toFloat() / fontMap.contentScale.toFloat() + pad / fontMap.contentScale.toFloat() + yshift + + val transform = buildTransform { + multiply( + Matrix44( + pose.c0r0, pose.c1r0, pose.c2r0, pose.c3r0 / scale, + pose.c0r1, pose.c1r1, pose.c2r1, pose.c3r1 / scale, + pose.c0r2, pose.c1r2, pose.c2r2, pose.c3r2 / scale, + pose.c0r3, pose.c1r3, pose.c2r3, pose.c3r3, + ) + ) + multiply( + Matrix44( + -1.0, 0.0, 0.0, 0.0, + 0.0, -1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ) + ) + translate(-(x0 + x1) / 2.0, 0.0) + } + + val p00 = Vector2(x0.toDouble(), y0.toDouble()) + val p01 = Vector2(x0.toDouble(), y1.toDouble()) + val p10 = Vector2(x1.toDouble(), y0.toDouble()) + val p11 = Vector2(x1.toDouble(), y1.toDouble()) + + val t00 = p00.transform(transform) + val t01 = p01.transform(transform) + val t10 = p10.transform(transform) + val t11 = p11.transform(transform) + + lastPos = t00 + + val s0 = 0.0f + val t0 = 0.0f + val s1 = 1.0f + val t1 = 1.0f + + val w = (x1 - x0) + val h = (y1 - y0) + val z = quadCount.toFloat() + + val floatInstance = instance.toFloat() + + if (quadCount < maxQuads) { + bw.apply { + write(u0, v0); write(s0, t0, w, h); write(t00.x.toFloat(), t00.y.toFloat(), z); write( + floatInstance + ) + write(u1, v0); write(s1, t0, w, h); write(t10.x.toFloat(), t10.y.toFloat(), z); write( + floatInstance + ) + write(u1, v1); write(s1, t1, w, h); write(t11.x.toFloat(), t11.y.toFloat(), z); write( + floatInstance + ) + write(u0, v0); write(s0, t0, w, h); write(t00.x.toFloat(), t00.y.toFloat(), z); write( + floatInstance + ) + write(u0, v1); write(s0, t1, w, h); write(t01.x.toFloat(), t01.y.toFloat(), z); write( + floatInstance + ) + write(u1, v1); write(s1, t1, w, h); write(t11.x.toFloat(), t11.y.toFloat(), z); write( + floatInstance + ) + } + quadCount++ + } + GlyphRectangle(character, x0.toDouble(), y0.toDouble(), (x1 - x0).toDouble(), (y1 - y0).toDouble()) + } + } else { + null + } + return Pair(x - cx, glyphRectangle) + } +} + +private val onContourImageMapDrawer by lazy { OnContourImageMapDrawer() } + +/** + * Draws a given text along the given contour + * + * @param text The string to be rendered along the contour. + * @param contour The rectified contour along which the text will be rendered. + * @param offsetX Optional horizontal offset to shift the starting point of the text along the contour. Default value is 0.0. + */ +fun Drawer.textOnContour(text: String, contour: RectifiedContour, offsetX: Double = 0.0) { + onContourImageMapDrawer.drawTextOnContour(contour, context, drawStyle, text, offsetX) +} diff --git a/orx-text-on-contour/src/jvmDemo/kotlin/DemoTextOnContour01.kt b/orx-text-on-contour/src/jvmDemo/kotlin/DemoTextOnContour01.kt new file mode 100644 index 00000000..b0662cba --- /dev/null +++ b/orx-text-on-contour/src/jvmDemo/kotlin/DemoTextOnContour01.kt @@ -0,0 +1,27 @@ +import org.openrndr.application +import org.openrndr.draw.loadFont +import org.openrndr.extra.shapes.rectify.rectified +import org.openrndr.extra.textoncontour.textOnContour +import org.openrndr.shape.Circle + +/** + * Demo Functionality includes: + * - Loading and applying a specific font (`IBMPlexMono-Regular`) with a size of 32.0. + * - Creating a circular contour at the center of the screen with a radius of 200.0. + * - Rendering text along the rectified circle's contour. + * - Offsetting text positions, enabling repeated text rendering along the same contour. + */ +fun main() = application { + configure { + width = 720 + height = 720 + } + program { + extend { + drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 32.0) + val c = Circle(drawer.bounds.center, 200.0).contour.rectified() + drawer.textOnContour("The wheels of the bus go round and round.", c) + drawer.textOnContour("The wheels of the bus go round and round.", c, c.contour.length / 2.0) + } + } +} \ No newline at end of file diff --git a/orx-text-writer/src/jvmDemo/kotlin/DemoTextWriter01.kt b/orx-text-writer/src/jvmDemo/kotlin/DemoTextWriter01.kt index 22733125..c88cbc6b 100644 --- a/orx-text-writer/src/jvmDemo/kotlin/DemoTextWriter01.kt +++ b/orx-text-writer/src/jvmDemo/kotlin/DemoTextWriter01.kt @@ -23,20 +23,24 @@ import org.openrndr.shape.Rectangle * - `drawer` enables isolated operations for drawing elements. * - `writer` facilitates text rendering with alignment and spacing adjustments. */ -fun main() = application { - program { - extend { - val r = Rectangle.fromCenter(drawer.bounds.center, 200.0, 200.0) - drawer.isolated { - drawer.fill = null - drawer.stroke = ColorRGBa.WHITE - drawer.rectangle(r) - } - drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 24.0) - writer { - box = r.offsetEdges(-10.0) - newLine() - text("hello world") +fun main() { + + application { + + program { + extend { + val r = Rectangle.fromCenter(drawer.bounds.center, 200.0, 200.0) + drawer.isolated { + drawer.fill = null + drawer.stroke = ColorRGBa.WHITE + drawer.rectangle(r) + } + drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 24.0) + writer { + box = r.offsetEdges(-10.0) + newLine() + text("hello world") + } } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index f5c444fb..9130ef44 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -87,6 +87,7 @@ include( "orx-depth-camera", "orx-jvm:orx-depth-camera-calibrator", "orx-view-box", + "orx-text-on-contour", "orx-text-writer", "orx-turtle" )