diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c84f7aa..d638f0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - position, rotation and size moved to properties - prototypes moved to children - Immutable Solid instances +- Property listeners are not triggered if there are no changes. +- Feedback websocket connection in the client. ### Deprecated diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionBase.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionBase.kt index 62f90f52..78b75124 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionBase.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionBase.kt @@ -74,9 +74,12 @@ public open class VisionBase( } override fun setProperty(name: Name, item: MetaItem?, notify: Boolean) { - getOrCreateProperties().setItem(name, item) - if (notify) { - invalidateProperty(name) + val oldItem = properties?.getItem(name) + if(oldItem!= item) { + getOrCreateProperties().setItem(name, item) + if (notify) { + invalidateProperty(name) + } } } diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionChange.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionChange.kt index 85b147ab..06ccb7bc 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionChange.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionChange.kt @@ -65,14 +65,14 @@ private fun Vision.isolate(manager: VisionManager): Vision { } /** - * @param void flag showing that this vision child should be removed + * @param delete flag showing that this vision child should be removed * @param vision a new value for vision content * @param properties updated properties - * @param children a map of children changed in ths [VisionChange]. If a child to be removed, set [void] flag to true. + * @param children a map of children changed in ths [VisionChange]. If a child to be removed, set [delete] flag to true. */ @Serializable public data class VisionChange( - public val void: Boolean = false, + public val delete: Boolean = false, public val vision: Vision? = null, @Serializable(MetaSerializer::class) public val properties: Meta? = null, public val children: Map? = null, diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroupBase.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroupBase.kt index 997789c1..933b55cd 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroupBase.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroupBase.kt @@ -134,7 +134,7 @@ public open class VisionGroupBase( override fun update(change: VisionChange) { change.children?.forEach { (name, change) -> when { - change.void -> set(name, null) + change.delete -> set(name, null) change.vision != null -> set(name, change.vision) else -> get(name)?.update(change) } diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt index b3e9faf4..4f01e839 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt @@ -39,6 +39,8 @@ public class VisionManager(meta: Meta) : AbstractPlugin(meta) { public fun decodeFromString(string: String): Vision = jsonFormat.decodeFromString(visionSerializer, string) public fun encodeToString(vision: Vision): String = jsonFormat.encodeToString(visionSerializer, vision) + public fun encodeToString(change: VisionChange): String = + jsonFormat.encodeToString(VisionChange.serializer(), change) public fun decodeFromJson(json: JsonElement): Vision = jsonFormat.decodeFromJsonElement(visionSerializer, json) diff --git a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/VisionClient.kt b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/VisionClient.kt index 98803115..017c615d 100644 --- a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/VisionClient.kt +++ b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/VisionClient.kt @@ -2,6 +2,9 @@ package space.kscience.visionforge import kotlinx.browser.document import kotlinx.browser.window +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.w3c.dom.* import org.w3c.dom.url.URL import space.kscience.dataforge.context.* @@ -13,14 +16,14 @@ import space.kscience.visionforge.html.VisionTagConsumer.Companion.OUTPUT_CONNEC import space.kscience.visionforge.html.VisionTagConsumer.Companion.OUTPUT_ENDPOINT_ATTRIBUTE import space.kscience.visionforge.html.VisionTagConsumer.Companion.OUTPUT_FETCH_ATTRIBUTE import space.kscience.visionforge.html.VisionTagConsumer.Companion.OUTPUT_NAME_ATTRIBUTE -import kotlin.collections.set import kotlin.reflect.KClass +import kotlin.time.Duration public class VisionClient : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag private val visionManager: VisionManager by require(VisionManager) - private val visionMap = HashMap() + //private val visionMap = HashMap() /** * Up-going tree traversal in search for endpoint attribute @@ -53,11 +56,73 @@ public class VisionClient : AbstractPlugin() { private fun Element.getFlag(attribute: String): Boolean = attributes[attribute]?.value != null - private fun renderVision(element: Element, vision: Vision?, outputMeta: Meta) { + private fun renderVision(name: String, element: Element, vision: Vision?, outputMeta: Meta) { if (vision != null) { - visionMap[element] = vision val renderer = findRendererFor(vision) ?: error("Could nof find renderer for $vision") renderer.render(element, vision, outputMeta) + + element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr -> + val wsUrl = if (attr.value.isBlank() || attr.value == "auto") { + val endpoint = resolveEndpoint(element) + logger.info { "Vision server is resolved to $endpoint" } + URL(endpoint).apply { + pathname += "/ws" + } + } else { + URL(attr.value) + }.apply { + protocol = "ws" + searchParams.append("name", name) + } + + logger.info { "Updating vision data from $wsUrl" } + + //Individual websocket for this element + WebSocket(wsUrl.toString()).apply { + onmessage = { messageEvent -> + val stringData: String? = messageEvent.data as? String + if (stringData != null) { + val change: VisionChange = visionManager.jsonFormat.decodeFromString( + VisionChange.serializer(), + stringData + ) + + if (change.vision != null) { + renderer.render(element, vision, outputMeta) + } + + logger.debug { "Got update $change for output with name $name" } + vision.update(change) + } else { + console.error("WebSocket message data is not a string") + } + } + + + //Backward change propagation + var feedbackJob: Job? = null + + onopen = { + feedbackJob = vision.flowChanges( + visionManager, + Duration.Companion.milliseconds(300) + ).onEach { change -> + send(visionManager.encodeToString(change)) + }.launchIn(visionManager.context) + + console.info("WebSocket update channel established for output '$name'") + } + + onclose = { + feedbackJob?.cancel() + console.info("WebSocket update channel closed for output '$name'") + } + onerror = { + feedbackJob?.cancel() + console.error("WebSocket update channel error for output '$name'") + } + } + } } } @@ -79,85 +144,39 @@ public class VisionClient : AbstractPlugin() { visionManager.decodeFromString(it) } - if (embeddedVision != null) { - logger.info { "Found embedded vision for output with name $name" } - renderVision(element, embeddedVision, outputMeta) - } - - element.attributes[OUTPUT_FETCH_ATTRIBUTE]?.let { attr -> - - val fetchUrl = if (attr.value.isBlank() || attr.value == "auto") { - val endpoint = resolveEndpoint(element) - logger.info { "Vision server is resolved to $endpoint" } - URL(endpoint).apply { - pathname += "/vision" - } - } else { - URL(attr.value) - }.apply { - searchParams.append("name", name) + when { + embeddedVision != null -> { + logger.info { "Found embedded vision for output with name $name" } + renderVision(name, element, embeddedVision, outputMeta) } + element.attributes[OUTPUT_FETCH_ATTRIBUTE] != null -> { + val attr = element.attributes[OUTPUT_FETCH_ATTRIBUTE]!! - logger.info { "Fetching vision data from $fetchUrl" } - window.fetch(fetchUrl).then { response -> - if (response.ok) { - response.text().then { text -> - val vision = visionManager.decodeFromString(text) - renderVision(element, vision, outputMeta) + val fetchUrl = if (attr.value.isBlank() || attr.value == "auto") { + val endpoint = resolveEndpoint(element) + logger.info { "Vision server is resolved to $endpoint" } + URL(endpoint).apply { + pathname += "/vision" } } else { - logger.error { "Failed to fetch initial vision state from $fetchUrl" } + URL(attr.value) + }.apply { + searchParams.append("name", name) } - } - } - - element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr -> - val wsUrl = if (attr.value.isBlank() || attr.value == "auto") { - val endpoint = resolveEndpoint(element) - logger.info { "Vision server is resolved to $endpoint" } - URL(endpoint).apply { - pathname += "/ws" - } - } else { - URL(attr.value) - }.apply { - protocol = "ws" - searchParams.append("name", name) - } - - logger.info { "Updating vision data from $wsUrl" } - - WebSocket(wsUrl.toString()).apply { - onmessage = { messageEvent -> - val stringData: String? = messageEvent.data as? String - if (stringData != null) { - val change = visionManager.jsonFormat.decodeFromString( - VisionChange.serializer(), - stringData - ) - - if (change.vision != null) { - renderVision(element, change.vision, outputMeta) + logger.info { "Fetching vision data from $fetchUrl" } + window.fetch(fetchUrl).then { response -> + if (response.ok) { + response.text().then { text -> + val vision = visionManager.decodeFromString(text) + renderVision(name, element, vision, outputMeta) } - - logger.debug { "Got update $change for output with name $name" } - visionMap[element]?.update(change) - ?: console.info("Target vision for element $element with name $name not found") } else { - console.error("WebSocket message data is not a string") + logger.error { "Failed to fetch initial vision state from $fetchUrl" } } } - onopen = { - console.info("WebSocket update channel established for output '$name'") - } - onclose = { - console.info("WebSocket update channel closed for output '$name'") - } - onerror = { - console.error("WebSocket update channel error for output '$name'") - } } + else -> error("No embedded vision data / fetch url for $name") } } diff --git a/visionforge-server/src/main/kotlin/space/kscience/visionforge/three/server/VisionServer.kt b/visionforge-server/src/main/kotlin/space/kscience/visionforge/three/server/VisionServer.kt index 6f0a3959..2c9ed631 100644 --- a/visionforge-server/src/main/kotlin/space/kscience/visionforge/three/server/VisionServer.kt +++ b/visionforge-server/src/main/kotlin/space/kscience/visionforge/three/server/VisionServer.kt @@ -19,7 +19,9 @@ import io.ktor.server.engine.embeddedServer import io.ktor.websocket.WebSockets import io.ktor.websocket.webSocket import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.html.* import kotlinx.html.stream.createHTML @@ -131,6 +133,15 @@ public class VisionServer internal constructor( application.log.debug("Opened server socket for $name") val vision: Vision = visions[name.toName()] ?: error("Plot with id='$name' not registered") + launch { + incoming.consumeEach { + val change = visionManager.jsonFormat.decodeFromString( + VisionChange.serializer(), it.data.decodeToString() + ) + vision.update(change) + } + } + try { withContext(visionManager.context.coroutineContext) { vision.flowChanges(visionManager, Duration.milliseconds(updateInterval)).collect { update -> diff --git a/visionforge-threejs/src/main/kotlin/space/kscience/visionforge/solid/three/csg.kt b/visionforge-threejs/src/main/kotlin/space/kscience/visionforge/solid/three/csg.kt index 2bee0a7a..50e48ee9 100644 --- a/visionforge-threejs/src/main/kotlin/space/kscience/visionforge/solid/three/csg.kt +++ b/visionforge-threejs/src/main/kotlin/space/kscience/visionforge/solid/three/csg.kt @@ -14,17 +14,17 @@ import info.laht.threekt.math.Matrix4 import info.laht.threekt.math.Vector3 import info.laht.threekt.objects.Mesh -external class CSG { - fun clone(): CSG - fun toPolygons(): Array - fun toGeometry(toMatrix: Matrix4): BufferGeometry - fun union(csg: CSG): CSG - fun subtract(csg: CSG): CSG - fun intersect(csg: CSG): CSG - fun inverse(): CSG +public external class CSG { + public fun clone(): CSG + public fun toPolygons(): Array + public fun toGeometry(toMatrix: Matrix4): BufferGeometry + public fun union(csg: CSG): CSG + public fun subtract(csg: CSG): CSG + public fun intersect(csg: CSG): CSG + public fun inverse(): CSG - companion object { + public companion object { fun fromPolygons(polygons: Array): CSG fun fromGeometry(geom: BufferGeometry, objectIndex: dynamic = definedExternally): CSG fun fromMesh(mesh: Mesh, objectIndex: dynamic = definedExternally): CSG