From fbf79f0a3739f23b53e245921aaafebd9171f4cc Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Mon, 22 Jul 2024 18:46:58 +0300 Subject: [PATCH] Implement new visualization --- CHANGELOG.md | 1 + build.gradle.kts | 2 +- .../controls/constructor/internalState.kt | 4 +- controls-visualisation-compose/README.md | 21 +++ .../build.gradle.kts | 24 +--- .../src/commonMain/kotlin/DeviceDrawable2D.kt | 64 +++++++++ .../kotlin/DeviceDrawable2DStore.kt | 91 +++++++++++++ demo/car/build.gradle.kts | 4 +- .../constructor/src/jvmMain/kotlin/Plotter.kt | 126 ++++++++++++------ demo/echo/build.gradle.kts | 4 +- demo/many-devices/build.gradle.kts | 4 +- gradle/libs.versions.toml | 4 +- magix/magix-java-endpoint/build.gradle.kts | 17 +-- 13 files changed, 280 insertions(+), 86 deletions(-) create mode 100644 controls-visualisation-compose/README.md create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8c47f..29b1a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Shortcuts to access all Controls devices in a magix network. - `DeviceClient` properly evaluates lifecycle and logs - `PeerConnection` API for direct device-device binary sharing +- DeviceDrawable2D intermediate visualization implementation ### Changed - Constructor properties return `DeviceState` in order to be able to subscribe to them diff --git a/build.gradle.kts b/build.gradle.kts index 773272c..7f7e534 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { allprojects { group = "space.kscience" - version = "0.4.0-dev-4" + version = "0.4.0-dev-5" repositories{ google() } diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt index 687804e..684f666 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.emptyFlow */ private class VirtualDeviceState( initialValue: T, - private val callback: (T) -> Unit = {}, + private val callback: (T) -> Unit = {} ) : MutableDeviceState { private val flow = MutableStateFlow(initialValue) override val valueFlow: Flow get() = flow @@ -34,7 +34,7 @@ private class VirtualDeviceState( */ public fun MutableDeviceState( initialValue: T, - callback: (T) -> Unit = {}, + callback: (T) -> Unit = {} ): MutableDeviceState = VirtualDeviceState(initialValue, callback) diff --git a/controls-visualisation-compose/README.md b/controls-visualisation-compose/README.md new file mode 100644 index 0000000..0f77d54 --- /dev/null +++ b/controls-visualisation-compose/README.md @@ -0,0 +1,21 @@ +# Module controls-visualisation-compose + +Visualisation extension using compose-multiplatform + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-visualisation-compose:0.4.0-dev-4`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-4") +} +``` diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts index d9d8215..0c68988 100644 --- a/controls-visualisation-compose/build.gradle.kts +++ b/controls-visualisation-compose/build.gradle.kts @@ -18,25 +18,11 @@ kscience { useContextReceivers() commonMain { api(projects.controlsConstructor) - api("io.github.koalaplot:koalaplot-core:0.6.0") - } -} - -kotlin { - sourceSets { - commonMain { - dependencies { - api(compose.foundation) - api(compose.material3) - @OptIn(ExperimentalComposeLibrary::class) - api(compose.desktop.components.splitPane) - } - } -// jvmMain { -// dependencies { -// implementation(compose.desktop.currentOs) -// } -// } + api(libs.koala.plots) + api(compose.foundation) + api(compose.material3) + @OptIn(ExperimentalComposeLibrary::class) + api(compose.desktop.components.splitPane) } } diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt new file mode 100644 index 0000000..464514b --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt @@ -0,0 +1,64 @@ +package space.kscience.controls.compose + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate + +/** + * A single 2D drawable + */ +@Immutable +public sealed interface DeviceDrawable2D { + + public fun DrawScope.draw() + + override fun equals(other: Any?): Boolean +} + +@Immutable +public data class CircleDrawable2D(val position: Offset, val radius: Float, val color: Color) : DeviceDrawable2D { + override fun DrawScope.draw() { + drawCircle(color, radius = radius, center = position) + } +} + +@Drawable2DBuilder +public fun DeviceDrawable2DStore.circle(id: String, position: Offset, radius: Float, color: Color) { + emit(id, CircleDrawable2D(position, radius, color)) +} + +@Immutable +public data class RectangleDrawable2D( + val position: Offset, + val rectangleSize: Size, + val color: Color, + val rotateDegrees: Float = 0f, +) : DeviceDrawable2D { + override fun DrawScope.draw() { + rotate(rotateDegrees) { + drawRect( + color = color, + topLeft = Offset( + (position.x - rectangleSize.width / 2), + (position.y - rectangleSize.height / 2) + ), + size = Size(rectangleSize.width, rectangleSize.height) + ) + } + } +} + +@Drawable2DBuilder +public fun DeviceDrawable2DStore.rectangle( + id: String, + position: Offset, + rectangleSize: Size, + color: Color, + rotateDegrees: Float = 0f, +) { + emit(id, RectangleDrawable2D(position, rectangleSize, color, rotateDegrees)) +} + diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt new file mode 100644 index 0000000..1c17aad --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt @@ -0,0 +1,91 @@ +package space.kscience.controls.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.toSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.Device +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.propertyFlow + +@DslMarker +public annotation class Drawable2DBuilder + +@Drawable2DBuilder +public class DeviceDrawable2DStore(public val scope: CoroutineScope, public val size: Size) { + public val drawableFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) +} + +public fun DeviceDrawable2DStore.emit(id: String, drawable2D: DeviceDrawable2D){ + drawableFlow.value += (id to drawable2D) +} + +public fun DeviceDrawable2DStore.emitAll(drawables: Map){ + drawableFlow.value += drawables +} + + +/** + * Fill drawables from a flow + */ +public fun DeviceDrawable2DStore.observe(id: String, flow: Flow): Job = flow.onEach { + drawableFlow.value += (id to it) +}.launchIn(scope) + +/** + * Observe single [DeviceState] + */ +public fun DeviceDrawable2DStore.observeState( + state: DeviceState, + id: String = state.toString(), + transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D, +): Job = observe(id, state.valueFlow.map { transform(this, it) }) + +/** + * Observe a single [Device] property + */ +public fun > DeviceDrawable2DStore.observeProperty( + device: D, + devicePropertySpec: DevicePropertySpec, + id: String = devicePropertySpec.toString(), + transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D, +): Job = observe(id, device.propertyFlow(devicePropertySpec).map { transform(this, it) }) + +@Composable +public fun Device2DCanvas( + modifier: Modifier = Modifier, + flowBuilder: suspend DeviceDrawable2DStore.() -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + var canvasSize by remember { mutableStateOf(Size(100f, 100f)) } + + val store = remember(canvasSize) { + DeviceDrawable2DStore(coroutineScope, canvasSize).apply { + coroutineScope.launch { + flowBuilder() + } + } + } + + val drawables by store.drawableFlow.collectAsState() + + key(store) { + Canvas(modifier.onGloballyPositioned { + canvasSize = it.size.toSize() + }) { + clipRect { + drawables.values.forEach { + with(it) { draw() } + } + } + } + } +} \ No newline at end of file diff --git a/demo/car/build.gradle.kts b/demo/car/build.gradle.kts index b8a7fb6..967b87d 100644 --- a/demo/car/build.gradle.kts +++ b/demo/car/build.gradle.kts @@ -37,8 +37,8 @@ kotlin{ } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt index 474f5c2..16006d6 100644 --- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt +++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt @@ -1,19 +1,29 @@ package space.kscience.controls.demo.constructor -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import org.jetbrains.compose.splitpane.HorizontalSplitPane +import space.kscience.controls.compose.* import space.kscience.controls.constructor.* import space.kscience.controls.constructor.devices.LimitSwitch import space.kscience.controls.constructor.devices.StepDrive @@ -121,24 +131,30 @@ private class PlotterModel( context = context, xDrive = xDrive, yDrive = yDrive, - xStartLimit = LimitSwitch(context,x.atStart), - xEndLimit = LimitSwitch(context,x.atEnd), - yStartLimit = LimitSwitch(context,x.atStart), - yEndLimit = LimitSwitch(context,x.atEnd), + xStartLimit = LimitSwitch(context, x.atStart), + xEndLimit = LimitSwitch(context, x.atEnd), + yStartLimit = LimitSwitch(context, x.atStart), + yEndLimit = LimitSwitch(context, x.atEnd), ) { color -> println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color") callback(PlotterPoint(x.value, y.value, color)) } } +private val range = -1000..1000 + +@OptIn(ExperimentalSplitPaneApi::class) suspend fun main() = application { Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { window.minimumSize = Dimension(400, 400) - val points = remember { mutableStateListOf() } - var position by remember { mutableStateOf(XY(0, 0)) } + val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { + var updateJob: Job? = remember { null } + + var points by remember { mutableStateOf>(emptyList()) } + + val plotterModel = remember { val context = Context { plugin(DeviceManager) plugin(ClockManager) @@ -146,53 +162,81 @@ suspend fun main() = application { /* Here goes the device definition block */ - val plotterModel = PlotterModel(context) { plotterPoint -> - points.add(plotterPoint) + PlotterModel(context) { plotterPoint -> + points += plotterPoint } - - /* Start visualization program */ - - plotterModel.xy.valueFlow.onEach { - position = it - }.launchIn(this) - - /* run program */ - - - val range = -1000..1000 -// plotterModel.plotter.modernArt(range, range) - plotterModel.plotter.square(range, range) - } - /* Here goes the visualization block */ MaterialTheme { - Canvas(modifier = Modifier.fillMaxSize()) { - fun toOffset(x: NumericalValue, y: NumericalValue): Offset { - val canvasX = (x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width - val canvasY = (y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height - return Offset(canvasX.toFloat(), canvasY.toFloat()) + HorizontalSplitPane { + first(200.dp) { + Column(modifier = Modifier.fillMaxHeight()) { + Button({ + updateJob?.cancel() + updateJob = scope.launch { + plotterModel.plotter.square(range, range) + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Rectangle") + } + Button({ + updateJob?.cancel() + updateJob = scope.launch { + plotterModel.plotter.modernArt(range, range) + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Modern Art") + } + Button({ + updateJob?.cancel() + }, modifier = Modifier.fillMaxWidth()) { + Text("Stop") + } + } + } + second { + Device2DCanvas(modifier = Modifier.fillMaxSize()) { + fun xToPx(x: NumericalValue): Float = + ((x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width).toFloat() - val center = toOffset(position.x, position.y) + fun yToPx(y: NumericalValue): Float = + ((y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height).toFloat() - drawRect( - Color.LightGray, - topLeft = Offset(0f, center.y - 5f), - size = Size(size.width, 10f) - ) + fun toOffset(xy: XY): Offset = Offset(xToPx(xy.x), yToPx(xy.y)) - drawCircle(Color.Black, radius = 10f, center = center) + observeState(plotterModel.y, "beam") { y -> + RectangleDrawable2D( + position = Offset(size.width / 2, yToPx(y)), + rectangleSize = Size(size.width, 10f), + color = Color.LightGray + ) + } + observeState(plotterModel.xy, "head") { xy -> + CircleDrawable2D( + position = toOffset(xy), + radius = 10f, + color = Color.Black + ) + } - points.forEach { - drawCircle(it.color, radius = 2f, center = toOffset(it.x, it.y)) + snapshotFlow { points }.onEach { + it.forEachIndexed { index, plotterPoint -> + circle( + "point[$index]", + Offset(xToPx(plotterPoint.x), yToPx(plotterPoint.y)), + radius = 5f, + color = plotterPoint.color + ) + } + }.launchIn(scope) + } } } } } - } \ No newline at end of file diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts index 873220e..ed722bf 100644 --- a/demo/echo/build.gradle.kts +++ b/demo/echo/build.gradle.kts @@ -21,8 +21,8 @@ kotlin{ } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts index 64fbfe2..5743341 100644 --- a/demo/many-devices/build.gradle.kts +++ b/demo/many-devices/build.gradle.kts @@ -26,8 +26,8 @@ kotlin{ tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c5a861..207964a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,7 +81,9 @@ visionforge-markdown = { module = "space.kscience:visionforge-markdown", version visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" } visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" } -sciprog-maps-compose = "space.kscience:maps-kt-compose:0.3.0" +sciprog-maps-compose = { module = "space.kscience:maps-kt-compose", version = "0.3.0" } + +koala-plots = { module = "io.github.koalaplot:koalaplot-core", version = "0.6.1" } # Buildscript diff --git a/magix/magix-java-endpoint/build.gradle.kts b/magix/magix-java-endpoint/build.gradle.kts index ce20b5d..68c7075 100644 --- a/magix/magix-java-endpoint/build.gradle.kts +++ b/magix/magix-java-endpoint/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import space.kscience.gradle.KScienceVersions import space.kscience.gradle.Maturity plugins { @@ -17,19 +15,6 @@ dependencies { implementation(spclibs.kotlinx.coroutines.jdk9) } -//java { -// sourceCompatibility = KScienceVersions.JVM_TARGET -// targetCompatibility = KScienceVersions.JVM_TARGET -//} - - -//FIXME https://youtrack.jetbrains.com/issue/KT-52815/Compiler-option-Xjdk-release-fails-to-compile-mixed-projects -tasks.withType{ - kotlinOptions { - freeCompilerArgs -= "-Xjdk-release=11" - } -} - -readme{ +readme { maturity = Maturity.EXPERIMENTAL } \ No newline at end of file