From 28663a010db65833b9d5de408a7a7faea48decb1 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Sun, 9 Oct 2022 13:03:36 +0300 Subject: [PATCH] Add SVG render for Scheme --- build.gradle.kts | 2 +- demo/scheme/src/jvmMain/kotlin/Main.kt | 64 ++- .../center/sciprog/maps/compose/MapViewJvm.kt | 19 +- .../center/sciprog/maps/coordinates/Angle.kt | 6 +- maps-kt-scheme/build.gradle.kts | 1 + .../center/sciprog/maps/scheme/SchemeView.kt | 26 +- .../center/sciprog/maps/svg/SvgCanvas.kt | 292 +++++++++++ .../center/sciprog/maps/svg/SvgDrawScope.kt | 464 ++++++++++++++++++ .../center/sciprog/maps/svg/exportToSvg.kt | 137 ++++++ 9 files changed, 977 insertions(+), 34 deletions(-) create mode 100644 maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgCanvas.kt create mode 100644 maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgDrawScope.kt create mode 100644 maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/exportToSvg.kt diff --git a/build.gradle.kts b/build.gradle.kts index 617d75d..87175b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ val ktorVersion by extra("2.0.3") allprojects { group = "center.sciprog" - version = "0.1.0-dev-10" + version = "0.1.0-dev-11" } ksciencePublish{ diff --git a/demo/scheme/src/jvmMain/kotlin/Main.kt b/demo/scheme/src/jvmMain/kotlin/Main.kt index 4e04b06..aea7d57 100644 --- a/demo/scheme/src/jvmMain/kotlin/Main.kt +++ b/demo/scheme/src/jvmMain/kotlin/Main.kt @@ -1,16 +1,22 @@ // Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import center.sciprog.maps.scheme.* +import center.sciprog.maps.svg.FeatureStateSnapshot +import center.sciprog.maps.svg.exportToSvg +import center.sciprog.maps.svg.snapshot import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.awt.Desktop +import java.nio.file.Files import kotlin.math.PI @Composable @@ -19,20 +25,13 @@ fun App() { MaterialTheme { val scope = rememberCoroutineScope() - - SchemeView( - config = SchemeViewConfig( - onClick = { - println("${focus.x}, ${focus.y}") - } - ) - ) { + val schemeFeaturesState = SchemeFeaturesState.remember { background(1600f, 1200f) { painterResource("middle-earth.jpg") } circle(410.52737 to 868.7676, color = Color.Blue) text(410.52737 to 868.7676, "Shire", color = Color.Blue) circle(1132.0881 to 394.99127, color = Color.Red) text(1132.0881 to 394.99127, "Ordruin", color = Color.Red) - arc(center = 1132.0881 to 394.99127, radius = 10f, startAngle = 0f, 2 * PI.toFloat()) + arc(center = 1132.0881 to 394.99127, radius = 20f, startAngle = 0f, 2 * PI.toFloat()) val hobbitId = circle(410.52737 to 868.7676) @@ -47,13 +46,52 @@ fun App() { if (t >= 1.0) t = 0.0 } } - } + + val initialViewPoint: SchemeViewPoint = remember { + schemeFeaturesState.features().values.computeBoundingBox(1f)?.computeViewPoint() + ?: SchemeViewPoint(SchemeCoordinates(0f, 0f)) + } + + var viewPoint by remember { mutableStateOf(initialViewPoint) } + + var snapshot: FeatureStateSnapshot? by remember { mutableStateOf(null) } + + if (snapshot == null) { + snapshot = schemeFeaturesState.snapshot() + } + + ContextMenuArea( + items = { + listOf( + ContextMenuItem("Export to SVG") { + snapshot?.let { + val path = Files.createTempFile("scheme-kt-", ".svg") + it.exportToSvg(viewPoint, 800.0, 800.0, path) + println(path.toFile()) + Desktop.getDesktop().browse(path.toFile().toURI()) + } + }, + ) + } + ) { + SchemeView( + initialViewPoint = initialViewPoint, + featuresState = schemeFeaturesState, + config = SchemeViewConfig( + onClick = { + println("${focus.x}, ${focus.y}") + }, + onViewChange = { viewPoint = this } + ), + ) + } + } } fun main() = application { - Window(onCloseRequest = ::exitApplication) { + Window(title = "Scheme demo", onCloseRequest = ::exitApplication) { App() } } diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt index 7ba95e4..5288a1a 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.* import androidx.compose.ui.input.pointer.* @@ -255,15 +256,17 @@ public actual fun MapView( val topLeft = feature.oval.topLeft.toOffset() val bottomRight = feature.oval.bottomRight.toOffset() - val path = Path().apply { - addArcRad( - Rect(topLeft, bottomRight), - feature.startAngle.radians.value.toFloat(), - feature.arcLength.radians.value.toFloat() - ) - } + val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y)) - drawPath(path, color = feature.color, style = Stroke()) + drawArc( + color = feature.color, + startAngle = feature.startAngle.degrees.toFloat(), + sweepAngle = feature.arcLength.degrees.toFloat(), + useCenter = false, + topLeft = topLeft, + size = size, + style = Stroke() + ) } diff --git a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Angle.kt b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Angle.kt index 0a144a4..f78c3e8 100644 --- a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Angle.kt +++ b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Angle.kt @@ -88,4 +88,8 @@ public val Number.degrees: Degrees get() = Degrees(toDouble()) public fun Angle.normalized(center: Angle = Angle.pi): Angle = this - Angle.piTimes2 * floor((radians.value + PI - center.radians.value) / PI/2) -public fun abs(angle: Angle): Angle = if (angle < Angle.zero) -angle else angle \ No newline at end of file +public fun abs(angle: Angle): Angle = if (angle < Angle.zero) -angle else angle + +public fun Radians.toFloat(): Float = value.toFloat() + +public fun Degrees.toFloat(): Float = value.toFloat() diff --git a/maps-kt-scheme/build.gradle.kts b/maps-kt-scheme/build.gradle.kts index 2818c03..1348dea 100644 --- a/maps-kt-scheme/build.gradle.kts +++ b/maps-kt-scheme/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { } val jvmMain by getting { dependencies { + implementation("org.jfree:org.jfree.svg:5.0.3") api(compose.desktop.currentOs) } } diff --git a/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/scheme/SchemeView.kt b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/scheme/SchemeView.kt index 2d68d8c..f161dd4 100644 --- a/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/scheme/SchemeView.kt +++ b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/scheme/SchemeView.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.* import androidx.compose.ui.input.pointer.* @@ -18,11 +19,13 @@ import androidx.compose.ui.unit.dp import mu.KotlinLogging import org.jetbrains.skia.Font import org.jetbrains.skia.Paint +import kotlin.math.PI +import kotlin.math.abs import kotlin.math.max import kotlin.math.min -private fun Color.toPaint(): Paint = Paint().apply { +internal fun Color.toSkiaPaint(): Paint = Paint().apply { isAntiAlias = true color = toArgb() } @@ -174,16 +177,17 @@ public fun SchemeView( val topLeft = feature.oval.leftTop.toOffset() val bottomRight = feature.oval.rightBottom.toOffset() - val path = Path().apply { - addArcRad( - Rect(topLeft, bottomRight), - feature.startAngle, - feature.arcLength - ) - } - - drawPath(path, color = feature.color, style = Stroke()) + val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y)) + drawArc( + color = feature.color, + startAngle = (feature.startAngle * 180 / PI).toFloat(), + sweepAngle = (feature.arcLength * 180 / PI).toFloat(), + useCenter = false, + topLeft = topLeft, + size = size, + style = Stroke() + ) } is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset()) @@ -204,7 +208,7 @@ public fun SchemeView( offset.x + 5, offset.y - 5, Font().apply { size = 16f }, - feature.color.toPaint() + feature.color.toSkiaPaint() ) } diff --git a/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgCanvas.kt b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgCanvas.kt new file mode 100644 index 0000000..9c62028 --- /dev/null +++ b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgCanvas.kt @@ -0,0 +1,292 @@ +package center.sciprog.maps.svg + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.DrawContext +import androidx.compose.ui.graphics.drawscope.DrawTransform +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import java.awt.Graphics2D + +internal fun Paint.toAwt(): java.awt.Paint { + return java.awt.Color(color.toArgb()) +} + + +internal fun DrawContext.asDrawTransform(): DrawTransform = object : DrawTransform { + override val size: Size + get() = this@asDrawTransform.size + + override val center: Offset + get() = size.center + + override fun inset(left: Float, top: Float, right: Float, bottom: Float) { + this@asDrawTransform.canvas.let { + val updatedSize = Size(size.width - (left + right), size.height - (top + bottom)) + require(updatedSize.width >= 0 && updatedSize.height >= 0) { + "Width and height must be greater than or equal to zero" + } + this@asDrawTransform.size = updatedSize + it.translate(left, top) + } + } + + override fun clipRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + clipOp: ClipOp, + ) { + this@asDrawTransform.canvas.clipRect(left, top, right, bottom, clipOp) + } + + override fun clipPath(path: Path, clipOp: ClipOp) { + this@asDrawTransform.canvas.clipPath(path, clipOp) + } + + override fun translate(left: Float, top: Float) { + this@asDrawTransform.canvas.translate(left, top) + } + + override fun rotate(degrees: Float, pivot: Offset) { + this@asDrawTransform.canvas.apply { + translate(pivot.x, pivot.y) + rotate(degrees) + translate(-pivot.x, -pivot.y) + } + } + + override fun scale(scaleX: Float, scaleY: Float, pivot: Offset) { + this@asDrawTransform.canvas.apply { + translate(pivot.x, pivot.y) + scale(scaleX, scaleY) + translate(-pivot.x, -pivot.y) + } + } + + override fun transform(matrix: Matrix) { + this@asDrawTransform.canvas.concat(matrix) + } +} + +internal class SvgCanvas(val graphics: Graphics2D) : Canvas { + override fun clipPath(path: Path, clipOp: ClipOp) { + TODO("Not yet implemented") + } + + override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) { + if (clipOp == ClipOp.Intersect) { + graphics.clipRect( + left.toInt(), + top.toInt(), + (right - left).toInt(), + (top - bottom).toInt() + ) + } else { + TODO() + } + } + + override fun concat(matrix: Matrix) { + TODO() +// matrix. +// val affine = AffineTransform() +// graphics.transform() + } + + override fun disableZ() { + //Do nothing + } + + override fun drawArc( + left: Float, + top: Float, + right: Float, + bottom: Float, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + paint: Paint, + ) { + graphics.paint = paint.toAwt() + graphics.drawArc( + top.toInt(), + left.toInt(), + (right - left).toInt(), + (top - bottom).toInt(), + startAngle.toInt(), + sweepAngle.toInt() + ) + } + + override fun drawCircle(center: Offset, radius: Float, paint: Paint) { + graphics.paint = paint.toAwt() + graphics.drawOval( + (center.x - radius).toInt(), + (center.y - radius).toInt(), + (radius * 2).toInt(), + (radius * 2).toInt() + ) + } + + override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) { + graphics.paint = paint.toAwt() + graphics.drawImage(image.toAwtImage(), null, topLeftOffset.x.toInt(), topLeftOffset.y.toInt()) + } + + override fun drawImageRect( + image: ImageBitmap, + srcOffset: IntOffset, + srcSize: IntSize, + dstOffset: IntOffset, + dstSize: IntSize, + paint: Paint, + ) { + TODO("Not yet implemented") + } + + override fun drawLine(p1: Offset, p2: Offset, paint: Paint) { + graphics.paint = paint.toAwt() + graphics.drawLine(p1.x.toInt(), p1.y.toInt(), p2.x.toInt(), p2.y.toInt()) + } + + override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + graphics.paint = paint.toAwt() + graphics.drawOval( + left.toInt(), + top.toInt(), + (right - left).toInt(), + (top - bottom).toInt() + ) + } + + override fun drawPath(path: Path, paint: Paint) { + val skiaPath = path.asSkiaPath() + val points: List = skiaPath.points.mapNotNull { it?.let { Offset(it.x, it.y) } } + drawPoints(PointMode.Lines, points, paint) + } + + override fun drawPoints(pointMode: PointMode, points: List, paint: Paint) { + graphics.paint = paint.toAwt() + val xs = IntArray(points.size) { points[it].x.toInt() } + val ys = IntArray(points.size) { points[it].y.toInt() } + when (pointMode) { + PointMode.Polygon -> { + graphics.drawPolygon(xs, ys, points.size) + } + + PointMode.Lines -> { + graphics.drawPolyline(xs, ys, points.size) + } + + PointMode.Points -> { + val diameter = paint.strokeWidth + if (paint.strokeCap == StrokeCap.Round) { + points.forEach { offset -> + graphics.fillOval( + (offset.x - diameter / 2).toInt(), + (offset.y - diameter / 2).toInt(), + diameter.toInt(), + diameter.toInt() + ) + } + } else { + points.forEach { offset -> + graphics.fillRect( + (offset.x - diameter / 2).toInt(), + (offset.y - diameter / 2).toInt(), + diameter.toInt(), + diameter.toInt() + ) + } + } + } + } + } + + override fun drawRawPoints(pointMode: PointMode, points: FloatArray, paint: Paint) { + require(points.size % 2 == 0) { "The number of floats must be even" } + val offsets = ArrayList(points.size / 2) + for (i in points.indices step 2) { + offsets.add(Offset(points[i], points[i + 1])) + } + drawPoints(pointMode, offsets, paint) + } + + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + graphics.paint = paint.toAwt() + graphics.drawRect( + left.toInt(), + top.toInt(), + (right - left).toInt(), + (top - bottom).toInt() + ) + } + + override fun drawRoundRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + radiusX: Float, + radiusY: Float, + paint: Paint, + ) { + graphics.paint = paint.toAwt() + graphics.drawRoundRect( + left.toInt(), + top.toInt(), + (right - left).toInt(), + (top - bottom).toInt(), + radiusX.toInt(), + radiusY.toInt() + ) + } + + override fun drawVertices(vertices: Vertices, blendMode: BlendMode, paint: Paint) { + TODO("Not yet implemented") + } + + override fun enableZ() { + //do nothing + } + + override fun restore() { + TODO("Not yet implemented") + } + + override fun rotate(degrees: Float) { + graphics.rotate(degrees.toDouble()) + } + + override fun save() { + TODO("Not yet implemented") + } + + override fun saveLayer(bounds: Rect, paint: Paint) { + TODO("Not yet implemented") + } + + override fun scale(sx: Float, sy: Float) { + graphics.scale(sx.toDouble(), sy.toDouble()) + } + + override fun skew(sx: Float, sy: Float) { + //TODO is this correct? + graphics.shear(sx.toDouble(), sy.toDouble()) + } + + override fun translate(dx: Float, dy: Float) { + graphics.translate(dx.toDouble(), dy.toDouble()) + } +} + +internal class SvgDrawContext(val graphics: Graphics2D, override var size: Size) : DrawContext { + override val canvas: Canvas = SvgCanvas(graphics) + + override val transform: DrawTransform = asDrawTransform() +} \ No newline at end of file diff --git a/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgDrawScope.kt b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgDrawScope.kt new file mode 100644 index 0000000..ba5323d --- /dev/null +++ b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/SvgDrawScope.kt @@ -0,0 +1,464 @@ +package center.sciprog.maps.svg + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.* +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import java.awt.BasicStroke +import java.awt.Font +import java.awt.Graphics2D +import java.awt.geom.AffineTransform +import java.awt.image.AffineTransformOp +import java.awt.Color as AWTColor + +private fun Color.toAWT(): java.awt.Color = AWTColor(toArgb()) +private fun Brush.toAWT(): java.awt.Paint = TODO() + +public class SvgDrawScope(public val graphics: Graphics2D, size: Size) : DrawScope { + + override val layoutDirection: LayoutDirection + get() = LayoutDirection.Ltr + + override val density: Float get() = 1f + + override val fontScale: Float get() = 1f + + override fun drawArc( + brush: Brush, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + when (style) { + Fill -> graphics.fillArc( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + startAngle.toInt(), + sweepAngle.toInt() + ) + + is Stroke -> graphics.drawArc( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + startAngle.toInt(), + sweepAngle.toInt() + ) + } + } + + override fun drawArc( + color: Color, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + when (style) { + Fill -> graphics.fillArc( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + startAngle.toInt(), + sweepAngle.toInt() + ) + + is Stroke -> graphics.drawArc( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + startAngle.toInt(), + sweepAngle.toInt() + ) + } + + } + + override fun drawCircle( + brush: Brush, + radius: Float, + center: Offset, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + when (style) { + Fill -> graphics.fillOval( + (center.x - radius).toInt(), + (center.y - radius).toInt(), + (radius * 2).toInt(), + (radius * 2).toInt() + ) + + is Stroke -> graphics.drawOval( + (center.x - radius).toInt(), + (center.y - radius).toInt(), + (radius * 2).toInt(), + (radius * 2).toInt() + ) + } + + } + + override fun drawCircle( + color: Color, + radius: Float, + center: Offset, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + when (style) { + Fill -> graphics.fillOval( + (center.x - radius).toInt(), + (center.y - radius).toInt(), + (radius * 2).toInt(), + (radius * 2).toInt() + ) + + is Stroke -> graphics.drawOval( + (center.x - radius).toInt(), + (center.y - radius).toInt(), + (radius * 2).toInt(), + (radius * 2).toInt() + ) + } + } + + override fun drawImage( + image: ImageBitmap, + topLeft: Offset, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.drawImage(image.toAwtImage(), null, topLeft.x.toInt(), topLeft.y.toInt()) + } + + override fun drawImage( + image: ImageBitmap, + srcOffset: IntOffset, + srcSize: IntSize, + dstOffset: IntOffset, + dstSize: IntSize, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + filterQuality: FilterQuality, + ) { + val scale: AffineTransform = AffineTransform.getScaleInstance( + dstSize.width.toDouble() / srcSize.width, + dstSize.height.toDouble() / srcSize.height + ) + val awtImage = image.toAwtImage().getSubimage(srcOffset.x, srcOffset.y, srcSize.width, srcSize.height) + val op = AffineTransformOp(scale, AffineTransformOp.TYPE_NEAREST_NEIGHBOR) + graphics.drawImage(awtImage, op, dstOffset.x, dstOffset.y) + } + + override fun drawImage( + image: ImageBitmap, + srcOffset: IntOffset, + srcSize: IntSize, + dstOffset: IntOffset, + dstSize: IntSize, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + val scale: AffineTransform = AffineTransform.getScaleInstance( + dstSize.width.toDouble() / srcSize.width, + dstSize.height.toDouble() / srcSize.height + ) + val awtImage = image.toAwtImage().getSubimage(srcOffset.x, srcOffset.y, srcSize.width, srcSize.height) + val op = AffineTransformOp(scale, AffineTransformOp.TYPE_NEAREST_NEIGHBOR) + graphics.drawImage(awtImage, op, dstOffset.x, dstOffset.y) + } + + override fun drawLine( + brush: Brush, + start: Offset, + end: Offset, + strokeWidth: Float, + cap: StrokeCap, + pathEffect: PathEffect?, + alpha: Float, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + graphics.stroke = BasicStroke(strokeWidth) + graphics.drawLine(start.x.toInt(), start.y.toInt(), end.x.toInt(), end.y.toInt()) + } + + override fun drawLine( + color: Color, + start: Offset, + end: Offset, + strokeWidth: Float, + cap: StrokeCap, + pathEffect: PathEffect?, + alpha: Float, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + graphics.stroke = BasicStroke(strokeWidth) + graphics.drawLine(start.x.toInt(), start.y.toInt(), end.x.toInt(), end.y.toInt()) + } + + override fun drawOval( + brush: Brush, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + when (style) { + Fill -> graphics.fillOval(topLeft.x.toInt(), topLeft.y.toInt(), size.width.toInt(), size.height.toInt()) + is Stroke -> graphics.drawOval( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt() + ) + } + } + + override fun drawOval( + color: Color, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + when (style) { + Fill -> graphics.fillOval(topLeft.x.toInt(), topLeft.y.toInt(), size.width.toInt(), size.height.toInt()) + is Stroke -> graphics.drawOval( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt() + ) + } + } + + override fun drawPath( + path: Path, + brush: Brush, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + val skiaPath = path.asSkiaPath() + val points = skiaPath.points.mapNotNull { it?.let { Offset(it.x, it.y) } } + drawPoints(points, PointMode.Lines, brush) + } + + override fun drawPath( + path: Path, + color: Color, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + val skiaPath = path.asSkiaPath() + val points = skiaPath.points.mapNotNull { it?.let { Offset(it.x, it.y) } } + drawPoints(points, PointMode.Lines, color) + } + + override fun drawPoints( + points: List, + pointMode: PointMode, + brush: Brush, + strokeWidth: Float, + cap: StrokeCap, + pathEffect: PathEffect?, + alpha: Float, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + graphics.stroke = BasicStroke(strokeWidth) + val xs = IntArray(points.size) { points[it].x.toInt() } + val ys = IntArray(points.size) { points[it].y.toInt() } + graphics.drawPolyline(xs, ys, points.size) + } + + override fun drawPoints( + points: List, + pointMode: PointMode, + color: Color, + strokeWidth: Float, + cap: StrokeCap, + pathEffect: PathEffect?, + alpha: Float, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + graphics.stroke = BasicStroke(strokeWidth) + val xs = IntArray(points.size) { points[it].x.toInt() } + val ys = IntArray(points.size) { points[it].y.toInt() } + graphics.drawPolyline(xs, ys, points.size) + } + + override fun drawRect( + brush: Brush, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + when (style) { + Fill -> graphics.fillRect(topLeft.x.toInt(), topLeft.y.toInt(), size.width.toInt(), size.height.toInt()) + is Stroke -> graphics.drawRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt() + ) + } + + } + + override fun drawRect( + color: Color, + topLeft: Offset, + size: Size, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + when (style) { + Fill -> graphics.fillRect(topLeft.x.toInt(), topLeft.y.toInt(), size.width.toInt(), size.height.toInt()) + is Stroke -> graphics.drawRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt() + ) + } + } + + override fun drawRoundRect( + brush: Brush, + topLeft: Offset, + size: Size, + cornerRadius: CornerRadius, + alpha: Float, + style: DrawStyle, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = brush.toAWT() + when (style) { + Fill -> graphics.fillRoundRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + cornerRadius.x.toInt(), + cornerRadius.y.toInt() + ) + + is Stroke -> graphics.drawRoundRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + cornerRadius.x.toInt(), + cornerRadius.y.toInt() + ) + } + + } + + override fun drawRoundRect( + color: Color, + topLeft: Offset, + size: Size, + cornerRadius: CornerRadius, + style: DrawStyle, + alpha: Float, + colorFilter: ColorFilter?, + blendMode: BlendMode, + ) { + graphics.paint = color.toAWT() + when (style) { + Fill -> graphics.fillRoundRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + cornerRadius.x.toInt(), + cornerRadius.y.toInt() + ) + + is Stroke -> graphics.drawRoundRect( + topLeft.x.toInt(), + topLeft.y.toInt(), + size.width.toInt(), + size.height.toInt(), + cornerRadius.x.toInt(), + cornerRadius.y.toInt() + ) + } + } + + fun drawText( + text: String, + x: Float, + y: Float, + font: Font, + color: Color, + ) { + graphics.paint = color.toAWT() + graphics.font = font + graphics.drawString(text, x, y) + } + + override val drawContext: DrawContext = SvgDrawContext(graphics, size) + +} \ No newline at end of file diff --git a/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/exportToSvg.kt b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/exportToSvg.kt new file mode 100644 index 0000000..65abe72 --- /dev/null +++ b/maps-kt-scheme/src/jvmMain/kotlin/center/sciprog/maps/svg/exportToSvg.kt @@ -0,0 +1,137 @@ +package center.sciprog.maps.svg + +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.painter.Painter +import center.sciprog.maps.scheme.* +import org.jfree.svg.SVGGraphics2D +import org.jfree.svg.SVGUtils +import java.awt.Font.PLAIN +import kotlin.math.PI +import kotlin.math.abs + + +class FeatureStateSnapshot( + val features: Map, + val painterCache: Map, +) + +@Composable +fun SchemeFeaturesState.snapshot(): FeatureStateSnapshot = + FeatureStateSnapshot( + features(), + features().values.filterIsInstance().associateWith { it.painter() }) + + +fun FeatureStateSnapshot.exportToSvg( + viewPoint: SchemeViewPoint, + width: Double, + height: Double, + path: java.nio.file.Path, +) { + + fun SchemeCoordinates.toOffset(): Offset = Offset( + (width / 2 + (x - viewPoint.focus.x) * viewPoint.scale).toFloat(), + (height / 2 + (viewPoint.focus.y - y) * viewPoint.scale).toFloat() + ) + + + fun SvgDrawScope.drawFeature(scale: Float, feature: SchemeFeature) { + when (feature) { + is SchemeBackgroundFeature -> { + val offset = SchemeCoordinates(feature.rectangle.left, feature.rectangle.top).toOffset() + val backgroundSize = Size( + (feature.rectangle.width * scale), + (feature.rectangle.height * scale) + ) + + translate(offset.x, offset.y) { + with(painterCache[feature]!!) { + draw(backgroundSize) + } + } + } + + is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale)) + is SchemeCircleFeature -> drawCircle( + feature.color, + feature.size, + center = feature.center.toOffset() + ) + + is SchemeLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) + is SchemeArcFeature -> { + val topLeft = feature.oval.leftTop.toOffset() + val bottomRight = feature.oval.rightBottom.toOffset() + + val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y)) + + drawArc( + color = feature.color, + startAngle = (feature.startAngle * 180 / PI).toFloat(), + sweepAngle = (feature.arcLength * 180 / PI).toFloat(), + useCenter = false, + topLeft = topLeft, + size = size, + style = Stroke() + ) + } + + is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset()) + + is SchemeImageFeature -> { + val offset = feature.position.toOffset() + val imageSize = feature.size.toSize() + translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) { + with(painterCache[feature]!!) { + draw(imageSize) + } + } + } + + is SchemeTextFeature -> drawIntoCanvas { canvas -> + val offset = feature.position.toOffset() + drawText( + feature.text, + offset.x + 5, + offset.y - 5, + java.awt.Font(null, PLAIN, 16), + feature.color + ) + } + + is SchemeDrawFeature -> { + val offset = feature.position.toOffset() + translate(offset.x, offset.y) { + feature.drawFeature(this) + } + } + + is SchemeFeatureGroup -> { + feature.children.values.forEach { + drawFeature(scale, it) + } + } + } + } + + val svgGraphics2D: SVGGraphics2D = SVGGraphics2D(width, height) + val svgScope = SvgDrawScope(svgGraphics2D, Size(width.toFloat(), height.toFloat())) + + svgScope.apply { + features.values.filterIsInstance().forEach { background -> + drawFeature(viewPoint.scale, background) + } + features.values.filter { + it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange + }.forEach { feature -> + drawFeature(viewPoint.scale, feature) + } + } + + SVGUtils.writeToSVG(path.toFile(), svgGraphics2D.svgElement) +} \ No newline at end of file