diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt index 00d408e..371e94f 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt @@ -32,12 +32,12 @@ public abstract class DeviceConstructor( _constructorElements.remove(constructorElement) } - override fun > registerAsProperty( + override fun > registerProperty( converter: MetaConverter, descriptor: PropertyDescriptor, state: S, ): S { - val res = super.registerAsProperty(converter, descriptor, state) + val res = super.registerProperty(converter, descriptor, state) registerElement(PropertyConstructorElement(this, descriptor.name, state)) return res } @@ -84,7 +84,7 @@ public fun > DeviceConstructor.property( PropertyDelegateProvider { _: DeviceConstructor, property -> val name = nameOverride ?: property.name val descriptor = PropertyDescriptor(name).apply(descriptorBuilder) - registerAsProperty(converter, descriptor, state) + registerProperty(converter, descriptor, state) ReadOnlyProperty { _: DeviceConstructor, _ -> state } @@ -145,6 +145,6 @@ public fun > DeviceConstructor.registerAsProperty( spec: DevicePropertySpec<*, T>, state: S, ): S { - registerAsProperty(spec.converter, spec.descriptor, state) + registerProperty(spec.converter, spec.descriptor, state) return state } diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt index 182d10b..a41899a 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt @@ -42,10 +42,15 @@ public open class DeviceGroup( } } - private class Action( - val invoke: suspend (Meta?) -> Meta?, + private class Action( + val inputConverter: MetaConverter, + val outputConverter: MetaConverter, val descriptor: ActionDescriptor, - ) + val action: suspend (T) -> R, + ) { + suspend operator fun invoke(argument: Meta?): Meta? = argument?.let { inputConverter.readOrNull(it) } + ?.let { action(it)?.let { outputConverter.convert(it) } } + } private val sharedMessageFlow = MutableSharedFlow() @@ -93,7 +98,7 @@ public open class DeviceGroup( /** * Register a new property based on [DeviceState]. Properties could be modified dynamically */ - public open fun > registerAsProperty( + public open fun > registerProperty( converter: MetaConverter, descriptor: PropertyDescriptor, state: S, @@ -112,7 +117,26 @@ public open class DeviceGroup( return state } - private val actions: MutableMap = hashMapOf() + private val actions: MutableMap> = hashMapOf() + + public fun registerAction( + inputConverter: MetaConverter, + outputConverter: MetaConverter, + descriptor: ActionDescriptor, + action: suspend (T) -> R, + ): suspend (T) -> R { + val name = descriptor.name.parseAsName() + require(actions[name] == null) { "Can't add action with name $name. It already exists." } + actions[name] = Action( + inputConverter = inputConverter, + outputConverter = outputConverter, + descriptor = descriptor, + action = action + ) + return { + action(it) + } + } override val propertyDescriptors: Collection get() = properties.values.map { it.descriptor } @@ -137,8 +161,8 @@ public open class DeviceGroup( override suspend fun execute(actionName: String, argument: Meta?): Meta? { - val action = actions[actionName] ?: error("Action with name $actionName not found") - return action.invoke(argument) + val action: Action<*, *> = actions[actionName] ?: error("Action with name $actionName not found") + return action(argument) } final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED @@ -176,7 +200,7 @@ public open class DeviceGroup( } public fun DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState) { - registerAsProperty(propertySpec.converter, propertySpec.descriptor, state) + registerProperty(propertySpec.converter, propertySpec.descriptor, state) } public fun DeviceManager.registerDeviceGroup( @@ -253,7 +277,7 @@ public fun DeviceGroup.registerAsProperty( state: DeviceState, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, ) { - registerAsProperty( + registerProperty( converter, PropertyDescriptor(name).apply(descriptorBuilder), state @@ -269,7 +293,7 @@ public fun DeviceGroup.registerMutableProperty( state: MutableDeviceState, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, ) { - registerAsProperty( + registerProperty( converter, PropertyDescriptor(name).apply(descriptorBuilder), state diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt index c326565..846a37b 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt @@ -74,7 +74,7 @@ public fun DeviceState.Companion.map( public fun DeviceState.map(mapper: (T) -> R): DeviceStateWithDependencies = DeviceState.map(this, mapper) -public fun DeviceState>.values(): DeviceState = object : DeviceState { +public fun DeviceState>.values(): DeviceState = object : DeviceState { override val value: Double get() = this@values.value.value diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt index 072789b..a8d9894 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt @@ -1,6 +1,5 @@ package space.kscience.controls.constructor.devices -import kotlinx.coroutines.launch import space.kscience.controls.constructor.* import space.kscience.controls.constructor.units.Degrees import space.kscience.controls.constructor.units.NumericalValue @@ -16,38 +15,36 @@ import kotlin.time.DurationUnit /** * A step drive * - * @param speed ticks per second + * @param ticksPerSecond ticks per second * @param target target ticks state * @param writeTicks a hardware callback */ public class StepDrive( context: Context, - speed: MutableDeviceState, + ticksPerSecond: MutableDeviceState, target: MutableDeviceState = MutableDeviceState(0), - private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit, + private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit = { _, _ -> }, ) : DeviceConstructor(context) { public val target: MutableDeviceState by property(MetaConverter.int, target) - public val speed: MutableDeviceState by property(MetaConverter.double, speed) + public val speed: MutableDeviceState by property(MetaConverter.double, ticksPerSecond) private val positionState = stateOf(target.value) public val position: DeviceState by property(MetaConverter.int, positionState) - private val ticker = onTimer { prev, next -> - val tickSpeed = speed.value + private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next -> + val tickSpeed = ticksPerSecond.value val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS) val ticksDelta: Int = target.value - position.value val steps: Int = when { ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToInt()) - ticksDelta < 0 -> max(ticksDelta, (timeDelta * tickSpeed).roundToInt()) + ticksDelta < 0 -> max(ticksDelta, -(timeDelta * tickSpeed).roundToInt()) else -> return@onTimer } - launch { - writeTicks(steps, tickSpeed) - positionState.value += steps - } + writeTicks(steps, tickSpeed) + positionState.value += steps } } @@ -55,7 +52,9 @@ public class StepDrive( * Compute a state using given tick-to-angle transformation */ public fun StepDrive.angle( - zero: NumericalValue, step: NumericalValue, -): DeviceState> = position.map { zero + it * step } + zero: NumericalValue = NumericalValue(0), +): DeviceState> = position.map { + zero + it.toDouble() * step +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt index 2bd9322..34a4910 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt @@ -10,7 +10,7 @@ private class StateFlowAsState( override var value: T by flow::value override val valueFlow: Flow get() = flow - override fun toString(): String = "FlowAsState()" + override fun toString(): String = "FlowAsState($value)" } /** 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 8bdfce1..687804e 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 @@ -23,7 +23,7 @@ private class VirtualDeviceState( callback(value) } - override fun toString(): String = "VirtualDeviceState()" + override fun toString(): String = "VirtualDeviceState($value)" } diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt index c760699..202fa71 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt @@ -16,7 +16,9 @@ public open class RangeState>( public val range: ClosedRange, ) : DeviceState { - override val valueFlow: Flow get() = input.valueFlow.map { it.coerceIn(range) } + override val valueFlow: Flow get() = input.valueFlow.map { + it.coerceIn(range) + } override val value: T get() = input.value.coerceIn(range) @@ -59,10 +61,10 @@ public fun MutableRangeState( public fun > DeviceState.coerceIn( - range: ClosedFloatingPointRange, + range: ClosedRange, ): RangeState = RangeState(this, range) public fun > MutableDeviceState.coerceIn( - range: ClosedFloatingPointRange, + range: ClosedRange, ): MutableRangeState = MutableRangeState(this, range) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt index 6e2f86c..5a6b6bc 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt @@ -3,20 +3,26 @@ package space.kscience.controls.constructor.models import space.kscience.controls.constructor.DeviceState import space.kscience.controls.constructor.ModelConstructor import space.kscience.controls.constructor.map -import space.kscience.controls.constructor.units.Meters -import space.kscience.controls.constructor.units.Newtons -import space.kscience.controls.constructor.units.NewtonsMeters -import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.* import space.kscience.dataforge.context.Context +import kotlin.math.PI public class ScrewDrive( context: Context, public val leverage: NumericalValue, ) : ModelConstructor(context) { + public fun transformForce( stateOfForce: DeviceState>, ): DeviceState> = DeviceState.map(stateOfForce) { - NumericalValue(it.value * leverage.value) + NumericalValue(it.value * leverage.value/2/ PI) + } + + public fun transformOffset( + stateOfAngle: DeviceState>, + offset: NumericalValue = NumericalValue(0), + ): DeviceState> = DeviceState.map(stateOfAngle) { + offset + NumericalValue(it.value * leverage.value/2/ PI) } } \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt new file mode 100644 index 0000000..15411f8 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt @@ -0,0 +1,17 @@ +package space.kscience.controls.constructor.models + +import space.kscience.controls.constructor.ModelConstructor +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.stateOf +import space.kscience.controls.constructor.units.Meters +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.dataforge.context.Context + +public class XYPosition( + context: Context, + initialX: NumericalValue = NumericalValue(0.0), + initialY: NumericalValue = NumericalValue(0.0), +) : ModelConstructor(context) { + public val x: MutableDeviceState> = stateOf(initialX) + public val y: MutableDeviceState> = stateOf(initialY) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt index 60de195..e503623 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt @@ -10,7 +10,7 @@ import kotlin.jvm.JvmInline * A value without identity coupled to units of measurements. */ @JvmInline -public value class NumericalValue(public val value: Double): Comparable> { +public value class NumericalValue(public val value: Double) : Comparable> { override fun compareTo(other: NumericalValue): Int = value.compareTo(other.value) } @@ -43,6 +43,9 @@ public operator fun NumericalValue.div( c: Number, ): NumericalValue = NumericalValue(this.value / c.toDouble()) +public operator fun NumericalValue.div(other: NumericalValue): Double = + value / other.value + private object NumericalValueMetaConverter : MetaConverter> { override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value) diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt index 7fcb5ab..e4de468 100644 --- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt +++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt @@ -23,19 +23,19 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit @Serializable -data class XY(val x: Double, val y: Double) { +private data class XY(val x: Double, val y: Double) { companion object { val ZERO = XY(0.0, 0.0) } } -val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2)) +private val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2)) -operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y) -operator fun XY.times(c: Double): XY = XY(x * c, y * c) -operator fun XY.div(c: Double): XY = XY(x / c, y / c) +private operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y) +private operator fun XY.times(c: Double): XY = XY(x * c, y * c) +private operator fun XY.div(c: Double): XY = XY(x / c, y / c) -class Spring( +private class Spring( context: Context, val k: Double, val l0: Double, @@ -70,7 +70,7 @@ class Spring( } } -class MaterialPoint( +private class MaterialPoint( context: Context, val mass: Double, val force: DeviceState, @@ -94,7 +94,7 @@ class MaterialPoint( } -class BodyOnSprings( +private class BodyOnSprings( context: Context, mass: Double, k: Double, diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt index 9129a1c..a77544f 100644 --- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt +++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt @@ -72,7 +72,7 @@ class Modulator( } -private val inertia = NumericalValue(0.1) +private val mass = NumericalValue(0.1) private val leverage = NumericalValue(0.05) @@ -83,7 +83,13 @@ private val range = -6.0..6.0 /** * The whole physical model is here */ -private fun createLinearDriveModel(context: Context, pidParameters: PidParameters): LinearDrive { +internal fun createLinearDriveModel( + context: Context, + pidParameters: PidParameters, + mass: NumericalValue, + leverage: NumericalValue, + position: MutableRangeState>, +): LinearDrive { //create a drive model with zero starting force val drive = Drive(context) @@ -91,8 +97,6 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter //a screw drive to converse a rotational moment into a linear one val screwDrive = ScrewDrive(context, leverage) - // Create a physical position coerced in a given range - val position = MutableRangeState(0.0, range) /** * Create an inertia model. @@ -101,10 +105,10 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter * Force is the input parameter, position is output parameter * */ - val inertia = Inertia.linear( + val inertiaModel = Inertia.linear( context = context, force = screwDrive.transformForce(drive.force), - mass = inertia, + mass = mass, position = position ) @@ -114,12 +118,12 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter val startLimitSwitch = LimitSwitch(context, position.atStart) val endLimitSwitch = LimitSwitch(context, position.atEnd) - return context.install( - "linearDrive", - LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters) - ) -} + /** + * Install the resulting device + */ + return LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters) +} private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install( "modulator", @@ -140,11 +144,21 @@ fun main() = application { } val linearDrive: LinearDrive = remember { - createLinearDriveModel(context, pidParameters) + context.install( + "linearDrive", + createLinearDriveModel( + context = context, + pidParameters = pidParameters, + mass = mass, + leverage = leverage, + // Create a physical position coerced in a given range + position = MutableRangeState(0.0, range) + ) + ) } val modulator = remember { - createModulator(linearDrive) + context.install("modulator", createModulator(linearDrive)) } //bind pid parameters diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt index bd0d366..9d48965 100644 --- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt +++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt @@ -1,2 +1,174 @@ package space.kscience.controls.demo.constructor +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +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.window.Window +import androidx.compose.ui.window.application +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.device +import space.kscience.controls.constructor.devices.StepDrive +import space.kscience.controls.constructor.devices.angle +import space.kscience.controls.constructor.models.RangeState +import space.kscience.controls.constructor.models.ScrewDrive +import space.kscience.controls.constructor.models.coerceIn +import space.kscience.controls.constructor.units.* +import space.kscience.controls.manager.ClockManager +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import java.awt.Dimension +import kotlin.random.Random + + +class Plotter( + context: Context, + xDrive: StepDrive, + yDrive: StepDrive, + val paint: suspend (Color) -> Unit, +) : DeviceConstructor(context) { + val xDrive by device(xDrive) + val yDrive by device(yDrive) + + public fun moveToXY(x: Int, y: Int) { + xDrive.target.value = x + yDrive.target.value = y + } + + //TODO add calibration + + // TODO add draw as action +} + +suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) { + while (isActive){ + val randomX = Random.nextInt(xRange.first, xRange.last) + val randomY = Random.nextInt(xRange.first, xRange.last) + moveToXY(randomX, randomY) + delay(500) + paint(Color(Random.nextInt())) + } +} + +suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) { + while (isActive) { + moveToXY(xRange.first, yRange.first) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.first, yRange.last) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.last, yRange.last) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.last, yRange.first) + delay(1000) + paint(Color.Red) + } +} + +private val xRange = NumericalValue(-0.5)..NumericalValue(0.5) +private val yRange = NumericalValue(-0.5)..NumericalValue(0.5) +private val ticksPerSecond = MutableDeviceState(250.0) +private val step = NumericalValue(1.2) + + +private data class PlotterPoint( + val x: NumericalValue, + val y: NumericalValue, + val color: Color = Color.Black, +) + +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(PlotterPoint(NumericalValue(0), NumericalValue(0))) } + + LaunchedEffect(Unit) { + val context = Context { + plugin(DeviceManager) + plugin(ClockManager) + } + + /* Here goes the device definition block */ + + + val xScrewDrive = ScrewDrive(context, NumericalValue(0.01)) + val xDrive = StepDrive(context, ticksPerSecond) + val x: RangeState> = xScrewDrive.transformOffset(xDrive.angle(step)).coerceIn(xRange) + + val yScrewDrive = ScrewDrive(context, NumericalValue(0.01)) + val yDrive = StepDrive(context, ticksPerSecond) + val y: RangeState> = yScrewDrive.transformOffset(yDrive.angle(step)).coerceIn(yRange) + + val plotter = Plotter(context, xDrive, yDrive) { color -> + println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color") + points.add(PlotterPoint(x.value, y.value, color)) + } + + + /* Start visualization program */ + + launch { + x.valueFlow.collect { + position = position.copy(x = it) + } + } + + launch { + y.valueFlow.collect { + position = position.copy(y = it) + } + } + + launch { + val range = -100..100 + plotter.modernArt(range, range) + //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()) + } + + val center = toOffset(position.x, position.y) + + + drawRect( + Color.LightGray, + topLeft = Offset(0f, center.y - 5f), + size = Size(size.width, 10f) + ) + + drawCircle(Color.Black, radius = 10f, center = center) + + + points.forEach { + drawCircle(it.color, radius = 2f, center = toOffset(it.x, it.y)) + } + } + } + } + +} \ No newline at end of file