diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62254097..f83d5ed5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ zxing = "3.5.3" ktor = "2.3.9" jgit = "6.9.0.202403050737-r" javaosc = "0.8" +jsoup = "1.17.2" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "kotlinLogging" } @@ -79,6 +80,7 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } antlr-core = { group = "org.antlr", name = "antlr4", version.ref = "antlr" } antlr-runtime = { group = "org.antlr", name = "antlr4-runtime", version.ref = "antlr" } antlr-kotlin-runtime = { group = "com.strumenta", name = "antlr-kotlin-runtime", version.ref = "antlrKotlin" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } diff --git a/orx-composition/README.md b/orx-composition/README.md new file mode 100644 index 00000000..cd80dafd --- /dev/null +++ b/orx-composition/README.md @@ -0,0 +1,5 @@ +# orx-composition + +Shape composition library + +This code was previously part of `openrndr-draw`. \ No newline at end of file diff --git a/orx-composition/build.gradle.kts b/orx-composition/build.gradle.kts new file mode 100644 index 00000000..9612c244 --- /dev/null +++ b/orx-composition/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + org.openrndr.extra.convention.`kotlin-multiplatform` + // kotlinx-serialization ends up on the classpath through openrndr-math and Gradle doesn't know which + // version was used. If openrndr were an included build, we probably wouldn't need to do this. + // https://github.com/gradle/gradle/issues/20084 + id(libs.plugins.kotlin.serialization.get().pluginId) +} + +kotlin { + sourceSets { + @Suppress("UNUSED_VARIABLE") + val commonMain by getting { + dependencies { + implementation(libs.openrndr.application) + implementation(libs.openrndr.draw) + implementation(libs.openrndr.filter) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.serialization.core) + } + } + + @Suppress("UNUSED_VARIABLE") + val jvmDemo by getting { + dependencies { + implementation(project(":orx-shapes")) + } + } + } +} \ No newline at end of file diff --git a/orx-composition/src/commonMain/kotlin/Composition.kt b/orx-composition/src/commonMain/kotlin/Composition.kt new file mode 100644 index 00000000..122f42a3 --- /dev/null +++ b/orx-composition/src/commonMain/kotlin/Composition.kt @@ -0,0 +1,589 @@ +package org.openrndr.extra.composition + +import org.openrndr.draw.* +import org.openrndr.math.* +import org.openrndr.math.transforms.* +import org.openrndr.shape.Rectangle +import org.openrndr.shape.Shape +import org.openrndr.shape.ShapeContour +import org.openrndr.shape.bounds +import kotlin.math.* +import kotlin.reflect.* + +/** + * Describes a node in a composition + */ +sealed class CompositionNode { + + var id: String? = null + + var parent: CompositionNode? = null + + /** This CompositionNode's own style. */ + var style: Style = Style() + + /** + * This CompositionNode's computed style. + * Where every style attribute is obtained by + * overwriting the Style in the following order: + * 1. Default style attributes. + * 2. Parent Node's computed style's inheritable attributes. + * 3. This Node's own style attributes. + */ + val effectiveStyle: Style + get() = when (val p = parent) { + is CompositionNode -> style inherit p.effectiveStyle + else -> style + } + + /** + * Custom attributes to be applied to the Node in addition to the Style attributes. + */ + var attributes = mutableMapOf() + + /** + * a map that stores user data + */ + val userData = mutableMapOf() + + /** + * a [Rectangle] that describes the bounding box of the contents + */ + abstract val bounds: Rectangle + + val effectiveStroke get() = effectiveStyle.stroke.value + val effectiveStrokeOpacity get() = effectiveStyle.strokeOpacity.value + val effectiveStrokeWeight get() = effectiveStyle.strokeWeight.value + val effectiveMiterLimit get() = effectiveStyle.miterLimit.value + val effectiveLineCap get() = effectiveStyle.lineCap.value + val effectiveLineJoin get() = effectiveStyle.lineJoin.value + val effectiveFill get() = effectiveStyle.fill.value + val effectiveFillOpacity get() = effectiveStyle.fillOpacity.value + val effectiveDisplay get() = effectiveStyle.display.value + val effectiveOpacity get() = effectiveStyle.opacity.value + val effectiveVisibility get() = effectiveStyle.visibility.value + val effectiveShadeStyle get() = effectiveStyle.shadeStyle.value + + /** Calculates the absolute transformation of the current node. */ + val effectiveTransform: Matrix44 + get() = when (val p = parent) { + is CompositionNode -> transform * p.effectiveTransform + else -> transform + } + + var stroke + get() = style.stroke.value + set(value) { + style.stroke = when (value) { + null -> Paint.None + else -> Paint.RGB(value) + } + } + var strokeOpacity + get() = style.strokeOpacity.value + set(value) { + style.strokeOpacity = Numeric.Rational(value) + } + var strokeWeight + get() = style.strokeWeight.value + set(value) { + style.strokeWeight = Length.Pixels(value) + } + var miterLimit + get() = style.miterLimit.value + set(value) { + style.miterLimit = Numeric.Rational(value) + } + var lineCap + get() = style.lineCap.value + set(value) { + style.lineCap = when (value) { + org.openrndr.draw.LineCap.BUTT -> LineCap.Butt + org.openrndr.draw.LineCap.ROUND -> LineCap.Round + org.openrndr.draw.LineCap.SQUARE -> LineCap.Square + } + } + var lineJoin + get() = style.lineJoin.value + set(value) { + style.lineJoin = when (value) { + org.openrndr.draw.LineJoin.BEVEL -> LineJoin.Bevel + org.openrndr.draw.LineJoin.MITER -> LineJoin.Miter + org.openrndr.draw.LineJoin.ROUND -> LineJoin.Round + } + } + var fill + get() = style.fill.value + set(value) { + style.fill = when (value) { + null -> Paint.None + else -> Paint.RGB(value) + } + } + var fillOpacity + get() = style.fillOpacity.value + set(value) { + style.fillOpacity = Numeric.Rational(value) + } + var opacity + get() = style.opacity.value + set(value) { + style.opacity = Numeric.Rational(value) + } + var shadeStyle + get() = style.shadeStyle.value + set(value) { + style.shadeStyle = Shade.Value(value) + } + var transform + get() = style.transform.value + set(value) { + style.transform = Transform.Matrix(value) + } +} + +// TODO: Deprecate this? +operator fun KMutableProperty0.setValue(thisRef: Style, property: KProperty<*>, value: ShadeStyle) { + this.set(Shade.Value(value)) +} + +fun transform(node: CompositionNode): Matrix44 = + (node.parent?.let { transform(it) } ?: Matrix44.IDENTITY) * node.transform + +/** + * a [CompositionNode] that holds a single image [ColorBuffer] + */ +class ImageNode(var image: ColorBuffer, var x: Double, var y: Double, var width: Double, var height: Double) : + CompositionNode() { + override val bounds: Rectangle + get() = Rectangle(0.0, 0.0, width, height).contour.transform(transform(this)).bounds +} + +/** + * a [CompositionNode] that holds a single [Shape] + */ +class ShapeNode(var shape: Shape) : CompositionNode() { + override val bounds: Rectangle + get() { + val t = effectiveTransform + return if (t === Matrix44.IDENTITY) { + shape.bounds + } else { + shape.bounds.contour.transform(t).bounds + } + } + + /** + * apply transforms of all ancestor nodes and return a new detached org.openrndr.shape.ShapeNode with conflated transform + */ + fun conflate(): ShapeNode { + return ShapeNode(shape).also { + it.id = id + it.parent = parent + it.style = style + it.transform = transform(this) + it.attributes = attributes + } + } + + /** + * apply transforms of all ancestor nodes and return a new detached shape node with identity transform and transformed Shape + */ + fun flatten(): ShapeNode { + return ShapeNode(shape.transform(transform(this))).also { + it.id = id + it.parent = parent + it.style = effectiveStyle + it.attributes = attributes + } + } + + fun copy( + id: String? = this.id, + parent: CompositionNode? = null, + style: Style = this.style, + attributes: MutableMap = this.attributes, + shape: Shape = this.shape + ): ShapeNode { + return ShapeNode(shape).also { + it.id = id + it.parent = parent + it.style = style + it.attributes = attributes + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ShapeNode) return false + if (shape != other.shape) return false + return true + } + + override fun hashCode(): Int { + return shape.hashCode() + } + + /** + * the local [Shape] with the [effectiveTransform] applied to it + */ + val effectiveShape + get() = shape.transform(effectiveTransform) +} + +/** + * a [CompositionNode] that holds a single text + */ +data class TextNode(var text: String, var contour: ShapeContour?) : CompositionNode() { + // TODO: This should not be Rectangle.EMPTY + override val bounds: Rectangle + get() = Rectangle.EMPTY +} + +/** + * A [CompositionNode] that functions as a group node + */ +open class GroupNode(open val children: MutableList = mutableListOf()) : CompositionNode() { + override val bounds: Rectangle + get() { + return children.map { it.bounds }.bounds + } + + fun copy( + id: String? = this.id, + parent: CompositionNode? = null, + style: Style = this.style, + children: MutableList = this.children + ): GroupNode { + return GroupNode(children).also { + it.id = id + it.parent = parent + it.style = style + it.attributes = attributes + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GroupNode) return false + + if (children != other.children) return false + return true + } + + override fun hashCode(): Int { + return children.hashCode() + } +} + +data class CompositionDimensions(val x: Length, val y: Length, val width: Length, val height: Length) { + val position = Vector2((x as Length.Pixels).value, (y as Length.Pixels).value) + val dimensions = Vector2((width as Length.Pixels).value, (height as Length.Pixels).value) + + constructor(rectangle: Rectangle) : this( + rectangle.corner.x.pixels, + rectangle.corner.y.pixels, + rectangle.dimensions.x.pixels, + rectangle.dimensions.y.pixels + ) + + override fun toString(): String = "$x $y $width $height" + + // I'm not entirely sure why this is needed but + // but otherwise equality checks will never succeed + override fun equals(other: Any?): Boolean { + return other is CompositionDimensions + && x.value == other.x.value + && y.value == other.y.value + && width.value == other.width.value + && height.value == other.height.value + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + width.hashCode() + result = 31 * result + height.hashCode() + return result + } +} + +val defaultCompositionDimensions = CompositionDimensions(0.0.pixels, 0.0.pixels, 768.0.pixels, 576.0.pixels) + + +class GroupNodeStop(children: MutableList) : GroupNode(children) + +/** + * A vector composition. + * @param root the root node of the composition + * @param bounds the dimensions of the composition + */ +class Composition(val root: CompositionNode, var bounds: CompositionDimensions = defaultCompositionDimensions) { + constructor(root: CompositionNode, bounds: Rectangle) : this(root, CompositionDimensions(bounds)) + + /** SVG/XML namespaces */ + val namespaces = mutableMapOf() + + var style: Style = Style() + + /** + * The style attributes affecting the whole document, such as the viewBox area and aspect ratio. + */ + var documentStyle: DocumentStyle = DocumentStyle() + + init { + val (x, y, width, height) = bounds + style.x = x + style.y = y + style.width = width + style.height = height + } + + fun findShapes() = root.findShapes() + fun findShape(id: String): ShapeNode? { + return (root.find { it is ShapeNode && it.id == id }) as? ShapeNode + } + + fun findImages() = root.findImages() + fun findImage(id: String): ImageNode? { + return (root.find { it is ImageNode && it.id == id }) as? ImageNode + } + + fun findGroups(): List = root.findGroups() + fun findGroup(id: String): GroupNode? { + return (root.find { it is GroupNode && it.id == id }) as? GroupNode + } + + fun clear() = (root as? GroupNode)?.children?.clear() + + /** Calculates the equivalent of `1%` in pixels. */ + internal fun normalizedDiagonalLength(): Double = sqrt(bounds.dimensions.squaredLength / 2.0) + + /** + * Calculates effective viewport transformation using [viewBox] and [preserveAspectRatio]. + * As per [the SVG 2.0 spec](https://svgwg.org/svg2-draft/single-page.html#coords-ComputingAViewportsTransform). + */ + fun calculateViewportTransform(): Matrix44 { + return when (documentStyle.viewBox) { + ViewBox.None -> Matrix44.IDENTITY + is ViewBox.Value -> { + when (val vb = (documentStyle.viewBox as ViewBox.Value).value) { + Rectangle.EMPTY -> { + // The intent is to not display the element + Matrix44.ZERO + } + + else -> { + val vbCorner = vb.corner + val vbDims = vb.dimensions + val eCorner = bounds.position + val eDims = bounds.dimensions + val (align, meetOrSlice) = documentStyle.preserveAspectRatio + + val scale = (eDims / vbDims).let { + if (align != Align.NONE) { + if (meetOrSlice == MeetOrSlice.MEET) { + Vector2(min(it.x, it.y)) + } else { + Vector2(max(it.x, it.y)) + } + } else { + it + } + } + + val translate = (eCorner - (vbCorner * scale)).let { + val cx = eDims.x - vbDims.x * scale.x + val cy = eDims.y - vbDims.y * scale.y + it + when (align) { + // TODO: This first one probably doesn't comply with the spec + Align.NONE -> Vector2.ZERO + Align.X_MIN_Y_MIN -> Vector2.ZERO + Align.X_MID_Y_MIN -> Vector2(cx / 2, 0.0) + Align.X_MAX_Y_MIN -> Vector2(cx, 0.0) + Align.X_MIN_Y_MID -> Vector2(0.0, cy / 2) + Align.X_MID_Y_MID -> Vector2(cx / 2, cy / 2) + Align.X_MAX_Y_MID -> Vector2(cx, cy / 2) + Align.X_MIN_Y_MAX -> Vector2(0.0, cy) + Align.X_MID_Y_MAX -> Vector2(cx / 2, cy) + Align.X_MAX_Y_MAX -> Vector2(cx, cy) + } + } + + buildTransform { + translate(translate) + scale(scale.x, scale.y, 1.0) + } + } + } + } + } + } +} + +/** + * remove node from its parent [CompositionNode] + */ +fun CompositionNode.remove() { + require(parent != null) { "parent is null" } + val parentGroup = (parent as? GroupNode) + if (parentGroup != null) { + val filtered = parentGroup.children.filter { + it != this + } + parentGroup.children.clear() + parentGroup.children.addAll(filtered) + } + parent = null +} + +fun CompositionNode.findTerminals(filter: (CompositionNode) -> Boolean): List { + val result = mutableListOf() + fun find(node: CompositionNode) { + when (node) { + is GroupNode -> node.children.forEach { find(it) } + else -> if (filter(node)) { + result.add(node) + } + } + } + find(this) + return result +} + +fun CompositionNode.findAll(filter: (CompositionNode) -> Boolean): List { + val result = mutableListOf() + fun find(node: CompositionNode) { + if (filter(node)) { + result.add(node) + } + if (node is GroupNode) { + node.children.forEach { find(it) } + } + } + find(this) + return result +} + +/** + * Finds first [CompositionNode] to match the given [predicate]. + */ +fun CompositionNode.find(predicate: (CompositionNode) -> Boolean): CompositionNode? { + if (predicate(this)) { + return this + } else if (this is GroupNode) { + val deque: ArrayDeque = ArrayDeque(children) + while (deque.isNotEmpty()) { + val node = deque.removeFirst() + if (predicate(node)) { + return node + } else if (node is GroupNode) { + deque.addAll(node.children) + } + } + } + return null +} + +/** + * find all descendant [ShapeNode] nodes, including potentially this node + * @return a [List] of [ShapeNode] nodes + */ +fun CompositionNode.findShapes(): List = findTerminals { it is ShapeNode }.map { it as ShapeNode } + +/** + * find all descendant [ImageNode] nodes, including potentially this node + * @return a [List] of [ImageNode] nodes + */ +fun CompositionNode.findImages(): List = findTerminals { it is ImageNode }.map { it as ImageNode } + +/** + * find all descendant [GroupNode] nodes, including potentially this node + * @return a [List] of [GroupNode] nodes + */ +fun CompositionNode.findGroups(): List = findAll { it is GroupNode }.map { it as GroupNode } + +/** + * visit this [CompositionNode] and all descendant nodes and execute [visitor] + */ +fun CompositionNode.visitAll(visitor: (CompositionNode.() -> Unit)) { + visitor() + if (this is GroupNode) { + for (child in children) { + child.visitAll(visitor) + } + } +} + +/** + * org.openrndr.shape.UserData delegate + */ +class UserData( + val name: String, val initial: T +) { + @Suppress("UNCHECKED_CAST") + operator fun getValue(node: CompositionNode, property: KProperty<*>): T { + val value: T? = node.userData[name] as? T + return value ?: initial + } + + operator fun setValue(stylesheet: CompositionNode, property: KProperty<*>, value: T) { + stylesheet.userData[name] = value + } +} + +fun CompositionNode.filter(filter: (CompositionNode) -> Boolean): CompositionNode? { + val f = filter(this) + + if (!f) { + return null + } + + if (this is GroupNode) { + val copies = mutableListOf() + children.forEach { + val filtered = it.filter(filter) + if (filtered != null) { + when (filtered) { + is ShapeNode -> { + copies.add(filtered.copy(parent = this)) + } + + is GroupNode -> { + copies.add(filtered.copy(parent = this)) + } + + else -> { + + } + } + } + } + return GroupNode(children = copies) + } else { + return this + } +} + +fun CompositionNode.map(mapper: (CompositionNode) -> CompositionNode): CompositionNode { + val r = mapper(this) + return when (r) { + is GroupNodeStop -> { + r.copy().also { copy -> + copy.children.forEach { + it.parent = copy + } + } + } + + is GroupNode -> { + val copy = r.copy(children = r.children.map { it.map(mapper) }.toMutableList()) + copy.children.forEach { + it.parent = copy + } + copy + } + + else -> r + } +} \ No newline at end of file diff --git a/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt b/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt new file mode 100644 index 00000000..d48b126b --- /dev/null +++ b/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt @@ -0,0 +1,760 @@ +package org.openrndr.extra.composition + +import org.openrndr.collections.pflatMap +import org.openrndr.collections.pforEach +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.ColorBuffer +import org.openrndr.draw.LineCap +import org.openrndr.draw.LineJoin +import org.openrndr.math.Matrix44 +import org.openrndr.math.Vector2 +import org.openrndr.math.Vector3 +import org.openrndr.math.YPolarity +import org.openrndr.math.transforms.* +import org.openrndr.shape.* + +/** + * Used internally to define [ClipMode]s. + */ +enum class ClipOp { + DISABLED, + DIFFERENCE, + REVERSE_DIFFERENCE, + INTERSECT, + UNION +} + +/** + * Specifies if transformations should be kept separate + * or applied to the clipped object and reset to identity. + */ +enum class TransformMode { + KEEP, + APPLY +} + +/** + * Specifies in which way to combine [Shape]s + * to form a [Composition] + */ +enum class ClipMode(val grouped: Boolean, val op: ClipOp) { + DISABLED(false, ClipOp.DISABLED), + DIFFERENCE(false, ClipOp.DIFFERENCE), + DIFFERENCE_GROUP(true, ClipOp.DIFFERENCE), + REVERSE_DIFFERENCE(false, ClipOp.REVERSE_DIFFERENCE), + REVERSE_DIFFERENCE_GROUP(true, ClipOp.REVERSE_DIFFERENCE), + INTERSECT(false, ClipOp.INTERSECT), + INTERSECT_GROUP(true, ClipOp.INTERSECT), + UNION(false, ClipOp.UNION), + UNION_GROUP(true, ClipOp.UNION) +} + +/** + * The set of draw style properties used for rendering a [Composition] + */ +private data class CompositionDrawStyle( + var fill: ColorRGBa? = null, + var fillOpacity: Double = 1.0, + var stroke: ColorRGBa? = ColorRGBa.BLACK, + var strokeOpacity: Double = 1.0, + var strokeWeight: Double = 1.0, + var opacity: Double = 1.0, + var clipMode: ClipMode = ClipMode.DISABLED, + var mask: Shape? = null, + var transformMode: TransformMode = TransformMode.APPLY, + var lineCap: LineCap = LineCap.BUTT, + var lineJoin: LineJoin = LineJoin.MITER, + var miterlimit: Double = 4.0, + var visibility: Visibility = Visibility.Visible +) + +/** + * Data structure containing intersection information. + */ +data class ShapeNodeIntersection(val node: ShapeNode, val intersection: ContourIntersection) + +/** + * Data structure containing information about a point + * in a [ShapeContour] closest to some other 2D point. + */ +data class ShapeNodeNearestContour(val node: ShapeNode, val point: ContourPoint, val distanceDirection: Vector2, val distance: Double) + +/** + * Merges two lists of [ShapeNodeIntersection] removing duplicates under the + * given [threshold]. Used internally by [intersections]. + */ +fun List.merge(threshold: Double = 0.5): List { + val result = mutableListOf() + for (i in this) { + val nearest = result.minByOrNull { it.intersection.position.squaredDistanceTo(i.intersection.position) } + if (nearest == null) { + result.add(i) + } else if (nearest.intersection.position.squaredDistanceTo(i.intersection.position) >= threshold * threshold) { + result.add(i) + } + } + return result +} + + +/** + * A Drawer-like interface for the creation of Compositions + * This should be easier than creating Compositions manually + */ +class CompositionDrawer(documentBounds: CompositionDimensions = defaultCompositionDimensions, + composition: Composition? = null, + cursor: GroupNode? = composition?.root as? GroupNode +) { + val root = (composition?.root as? GroupNode) ?: GroupNode() + val composition = composition ?: Composition(root, documentBounds) + + var cursor = cursor ?: root + private set + + private val modelStack = ArrayDeque() + private val styleStack = ArrayDeque().apply { } + private var drawStyle = CompositionDrawStyle() + + var model = Matrix44.IDENTITY + + var fill + get() = drawStyle.fill?.opacify(drawStyle.fillOpacity)?.opacify(drawStyle.opacity) + set(value) = run { + drawStyle.fill = value?.copy(alpha = 1.0) + drawStyle.fillOpacity = value?.alpha ?: 1.0 + } + + var fillOpacity + get() = drawStyle.fillOpacity + set(value) = run { drawStyle.fillOpacity = value } + + var stroke + get() = drawStyle.stroke?.opacify(drawStyle.strokeOpacity)?.opacify(drawStyle.opacity) + set(value) = run { + drawStyle.stroke = value?.copy(alpha = 1.0) + drawStyle.strokeOpacity = value?.alpha ?: 1.0 + } + + var strokeOpacity + get() = drawStyle.strokeOpacity + set(value) = run { drawStyle.strokeOpacity = value } + + var strokeWeight + get() = drawStyle.strokeWeight + set(value) = run { drawStyle.strokeWeight = value } + + var miterlimit + get() = drawStyle.miterlimit + set(value) = run { drawStyle.miterlimit = value } + + var lineCap + get() = drawStyle.lineCap + set(value) = run { drawStyle.lineCap = value } + + var lineJoin + get() = drawStyle.lineJoin + set(value) = run { drawStyle.lineJoin = value } + + var opacity + get() = drawStyle.opacity + set(value) = run { drawStyle.opacity = value } + + var visibility + get() = drawStyle.visibility + set(value) = run { drawStyle.visibility = value } + + var clipMode + get() = drawStyle.clipMode + set(value) = run { drawStyle.clipMode = value } + + var mask: Shape? + get() = drawStyle.mask + set(value) = run { drawStyle.mask = value } + + var transformMode + get() = drawStyle.transformMode + set(value) = run { drawStyle.transformMode = value } + + fun pushModel() { + modelStack.addLast(model) + } + + fun popModel() { + model = modelStack.removeLast() + } + + fun pushStyle() { + styleStack.addLast(drawStyle.copy()) + } + + fun popStyle() { + drawStyle = styleStack.removeLast() + } + + fun isolated(draw: CompositionDrawer.() -> Unit) { + pushModel() + pushStyle() + draw() + popModel() + popStyle() + } + + fun GroupNode.with(builder: CompositionDrawer.() -> Unit): GroupNode { + val oldCursor = cursor + cursor = this + builder() + cursor = oldCursor + return this + } + + /** + * Create a group node and run `builder` inside its context + * @param insert if true the created group will be inserted at [cursor] + * @param id an optional identifier + * @param builder the function that is executed inside the group context + */ + fun group(insert: Boolean = true, id: String? = null, builder: CompositionDrawer.() -> Unit): GroupNode { + val group = GroupNode() + group.id = id + val oldCursor = cursor + + if (insert) { + cursor.children.add(group) + group.parent = cursor + } + cursor = group + builder() + + cursor = oldCursor + return group + } + + fun translate(x: Double, y: Double) = translate(Vector2(x, y)) + + fun rotate(rotationInDegrees: Double) { + model *= Matrix44.rotateZ(rotationInDegrees) + } + + fun scale(s: Double) { + model *= Matrix44.scale(s, s, s) + } + + fun scale(x: Double, y: Double) { + model *= Matrix44.scale(x, y, 1.0) + } + + fun translate(t: Vector2) { + model *= Matrix44.translate(t.vector3()) + } + + fun contour(contour: ShapeContour, insert: Boolean = true): ShapeNode? { + if (contour.empty) { + return null + } + val shape = Shape(listOf(contour)) + return shape(shape, insert) + } + + fun contours(contours: List, insert: Boolean = true) = contours.map { contour(it, insert) } + + /** + * Search for a point on a contour in the composition tree that's nearest to `point` + * @param point the query point + * @param searchFrom a node from which the search starts, defaults to composition root + * @return an optional org.openrndr.shape.ShapeNodeNearestContour instance + */ + fun nearest( + point: Vector2, + searchFrom: CompositionNode = composition.root as GroupNode + ): ShapeNodeNearestContour? { + return distances(point, searchFrom).firstOrNull() + } + + fun CompositionNode.nearest(point: Vector2) = nearest(point, searchFrom = this) + + fun difference( + shape: Shape, + searchFrom: CompositionNode = composition.root as GroupNode + ): Shape { + val shapes = searchFrom.findShapes() + var from = shape + + for (subtract in shapes) { + if (shape.bounds.intersects(subtract.shape.bounds)) { + from = difference(from, subtract.shape) + } + } + return from + } + + /** + * Find distances to each contour in the composition tree (or starting node) + * @param point the query point + * @param searchFrom a node from which the search starts, defaults to composition root + * @return a sorted list of [ShapeNodeNearestContour] describing distance to every contour + */ + fun distances( + point: Vector2, + searchFrom: CompositionNode = composition.root as GroupNode + ): List { + return searchFrom.findShapes().flatMap { node -> + node.shape.contours.filter { !it.empty } + .map { it.nearest(point) } + .map { ShapeNodeNearestContour(node, it, point - it.position, it.position.distanceTo(point)) } + }.sortedBy { it.distance } + } + + fun CompositionNode.distances(point: Vector2): List = distances(point, searchFrom = this) + + /** + * Test a given `contour` against org.openrndr.shape.contours in the composition tree + * @param contour the query contour + * @param searchFrom a node from which the search starts, defaults to composition root + * @param mergeThreshold minimum distance between intersections before they are merged together, + * 0.0 or lower means no org.openrndr.shape.merge + * @return a list of `org.openrndr.shape.ShapeNodeIntersection` + */ + fun intersections( + contour: ShapeContour, + searchFrom: CompositionNode = composition.root as GroupNode, + mergeThreshold: Double = 0.5 + ): List { + return searchFrom.findShapes().pflatMap { node -> + if (node.bounds.intersects(contour.bounds)) { + node.shape.contours.flatMap { nodeContour -> + intersections(contour, nodeContour).map { + ShapeNodeIntersection(node, it) + } + } + } else { + emptyList() + } + }.let { + if (mergeThreshold > 0.0) { + it.merge(mergeThreshold) + } else { + it + } + } + } + + fun CompositionNode.intersections(contour: ShapeContour, mergeThreshold: Double = 0.5) = + intersections(contour, this, mergeThreshold) + + /** + * Test a given `shape` against org.openrndr.shape.contours in the composition tree + * @param shape the query shape + * @param searchFrom a node from which the search starts, defaults to composition root + * @return a list of `org.openrndr.shape.ShapeNodeIntersection` + */ + fun intersections( + shape: Shape, + searchFrom: CompositionNode = composition.root as GroupNode, + mergeThreshold: Double = 0.5 + ): List { + return shape.contours.flatMap { + intersections(it, searchFrom, mergeThreshold) + } + } + + fun CompositionNode.intersections(shape: Shape, mergeThreshold: Double = 0.5) = + intersections(shape, this, mergeThreshold) + + + fun shape(shape: Shape, insert: Boolean = true): ShapeNode? { + if (shape.empty) { + return null + } + + val inverseModel = model.inversed + val postShape = mask?.let { intersection(shape, it.transform(inverseModel)) } ?: shape + + if (postShape.empty) { + return null + } + + // only use clipping for open shapes + val clipMode = if (postShape.topology == ShapeTopology.CLOSED) clipMode else ClipMode.DISABLED + + return when (clipMode.op) { + ClipOp.DISABLED, ClipOp.REVERSE_DIFFERENCE -> { + val shapeNode = ShapeNode(postShape) + + val shapeTransform: Matrix44 = when (transformMode) { + TransformMode.KEEP -> { + shapeNode.transform = model + Matrix44.IDENTITY + } + TransformMode.APPLY -> { + shapeNode.transform = Matrix44.IDENTITY + model + } + } + shapeNode.shape = when (clipMode.op) { + ClipOp.DISABLED -> postShape.transform(shapeTransform) + ClipOp.REVERSE_DIFFERENCE -> { + val shapeNodes = (if (!clipMode.grouped) composition.findShapes() else cursor.findShapes()) + var toInsert = shape + val inverse = model.inversed + for (node in shapeNodes) { + if (toInsert.empty) { + break + } else { + toInsert = difference(toInsert, node.effectiveShape.transform(inverse)) + } + } + toInsert + } + else -> error("unreachable") + } + shapeNode.stroke = stroke + shapeNode.strokeOpacity = strokeOpacity + shapeNode.strokeWeight = strokeWeight + shapeNode.miterLimit = miterlimit + shapeNode.lineCap = lineCap + shapeNode.lineJoin = lineJoin + shapeNode.fill = fill + shapeNode.fillOpacity = fillOpacity + + if (insert) { + cursor.children.add(shapeNode) + shapeNode.parent = cursor + } + shapeNode + } + else -> { + val shapeNodes = (if (!clipMode.grouped) composition.findShapes() else cursor.findShapes()) + val toRemove = mutableListOf() + shapeNodes.pforEach { shapeNode -> + val inverse = shapeNode.effectiveTransform.inversed + val transformedShape = postShape.transform(inverse * model) + val operated = + when (clipMode.op) { + ClipOp.INTERSECT -> intersection(shapeNode.shape, transformedShape) + ClipOp.UNION -> union(shapeNode.shape, transformedShape) + ClipOp.DIFFERENCE -> difference(shapeNode.shape, transformedShape) + else -> error("unsupported base op ${clipMode.op}") + } + if (!operated.empty) { + shapeNode.shape = operated + } else { + //synchronized(toRemove) { + toRemove.add(shapeNode) + //} + } + } + for (node in toRemove) { + node.remove() + } + null + } + } + } + + fun shapes(shapes: List, insert: Boolean = true) = shapes.map { shape(it, insert) } + + fun rectangle(rectangle: Rectangle, closed: Boolean = true, insert: Boolean = true) = contour(rectangle.contour.let { + if (closed) { + it + } else { + it.open + } + }, insert = insert) + + fun rectangle(x: Double, y: Double, width: Double, height: Double, closed: Boolean = true, insert: Boolean = true) = rectangle( + Rectangle(x, y, width, height), closed, insert) + + fun rectangles(rectangles: List, insert: Boolean = true) = rectangles.map { rectangle(it, insert) } + + fun rectangles(positions: List, width: Double, height: Double, insert: Boolean = true) = rectangles(positions.map { + Rectangle(it, width, height) + }, insert) + + fun rectangles(positions: List, dimensions: List, insert: Boolean) = rectangles((positions zip dimensions).map { + Rectangle(it.first, it.second.x, it.second.y) + }, insert) + + fun circle(x: Double, y: Double, radius: Double, closed: Boolean = true, insert: Boolean = true) = circle( + Circle( + Vector2(x, y), + radius + ), closed, insert) + + fun circle(position: Vector2, radius: Double, closed: Boolean = true, insert: Boolean = true) = circle( + Circle( + position, + radius + ), closed, insert) + + fun circle(circle: Circle, closed: Boolean = true, insert: Boolean = true) = contour(circle.contour.let { + if (closed) { + it + } else { + it.open + } + }, insert) + + fun circles(circles: List, insert: Boolean = true) = circles.map { circle(it, insert) } + + fun circles(positions: List, radius: Double, insert: Boolean = true) = circles(positions.map { + Circle( + it, + radius + ) + }, insert) + + fun circles(positions: List, radii: List, insert: Boolean = true) = circles((positions zip radii).map { + Circle( + it.first, + it.second + ) + }, insert) + + /* + fun ellipse( + x: Double, + y: Double, + xRadius: Double, + yRadius: Double, + rotationInDegrees: Double = 0.0, + closed: Boolean = true, + insert: Boolean = true + ) = ellipse(Vector2(x, y), xRadius, yRadius, rotationInDegrees, closed, insert) + + fun ellipse( + center: Vector2, + xRadius: Double, + yRadius: Double, + rotationInDegrees: Double, + closed: Boolean = true, + insert: Boolean = true + ) = contour(OrientedEllipse(center, xRadius, yRadius, rotationInDegrees).contour.let { + if (closed) { + it + } else { + it.open + } + }, insert) + */ + + + fun lineSegment( + startX: Double, + startY: Double, + endX: Double, + endY: Double, + insert: Boolean = true + ) = lineSegment(LineSegment(startX, startY, endX, endY), insert) + + fun lineSegment( + start: Vector2, + end: Vector2, + insert: Boolean = true + ) = lineSegment(LineSegment(start, end), insert) + + fun lineSegment( + lineSegment: LineSegment, + insert: Boolean = true + ) = contour(lineSegment.contour, insert) + + fun lineSegments( + lineSegments: List, + insert: Boolean = true + ) = lineSegments.map { + lineSegment(it, insert) + } + + fun segment( + start: Vector2, + c0: Vector2, + c1: Vector2, + end: Vector2, + insert: Boolean = true + ) = segment(Segment(start, c0, c1, end), insert) + + fun segment( + start: Vector2, + c0: Vector2, + end: Vector2, + insert: Boolean = true + ) = segment(Segment(start, c0, end), insert) + + fun segment( + start: Vector2, + end: Vector2, + insert: Boolean = true + ) = segment(Segment(start, end), insert) + + fun segment( + segment: Segment, + insert: Boolean = true + ) = contour(segment.contour, insert) + + fun segments( + segments: List, + insert: Boolean = true + ) = segments.map { + segment(it, insert) + } + + fun lineStrip( + points: List, + insert: Boolean = true + ) = contour(ShapeContour.fromPoints(points, false, YPolarity.CW_NEGATIVE_Y), insert) + + fun lineLoop( + points: List, + insert: Boolean = true + ) = contour(ShapeContour.fromPoints(points, true, YPolarity.CW_NEGATIVE_Y), insert) + + fun text( + text: String, + position: Vector2, + insert: Boolean = true + ): TextNode { + val g = GroupNode() + g.style.transform = Transform.Matrix(transform { translate(position.xy0) }) + val textNode = TextNode(text, null).apply { + this.style.fill = when (val f = this@CompositionDrawer.fill) { + is ColorRGBa -> Paint.RGB(f) + else -> Paint.None + } + + } + g.children.add(textNode) + if (insert) { + cursor.children.add(g) + } + return textNode + } + + fun textOnContour( + text: String, + contour: ShapeContour, + insert: Boolean = true + ): TextNode { + val textNode = TextNode(text, contour) + if (insert) { + cursor.children.add(textNode) + } + return textNode + } + + fun texts(text: List, positions: List) = + (text zip positions).map { + text(it.first, it.second) + } + + /** + * Adds an image to the composition tree + */ + fun image( + image: ColorBuffer, + x: Double = 0.0, + y: Double = 0.0, + insert: Boolean = true + ): ImageNode { + val node = ImageNode(image, x, y, width = image.width.toDouble(), height = image.height.toDouble()) + node.style.transform = Transform.Matrix(this.model) + if (insert) { + cursor.children.add(node) + } + return node + } + + fun composition(composition: Composition): CompositionNode { + val rootContainer = GroupNode() + val newRoot = composition.root.duplicate(insert = false) + newRoot.parent = rootContainer + rootContainer.children.add(newRoot) + rootContainer.transform *= model + rootContainer.parent = cursor + cursor.children.add(rootContainer) + + return rootContainer + } + + fun CompositionNode.translate(x: Double, y: Double, z: Double = 0.0) { + transform = transform.transform { + translate(x, y, z) + } + } + + fun CompositionNode.rotate(angleInDegrees: Double, pivot: Vector2 = Vector2.ZERO) { + transform = transform.transform { + translate(pivot.xy0) + rotate(Vector3.UNIT_Z, angleInDegrees) + translate(-pivot.xy0) + } + } + + fun CompositionNode.scale(scale: Double, pivot: Vector2 = Vector2.ZERO) { + transform = transform.transform { + translate(pivot.xy0) + scale(scale, scale, scale) + translate(-pivot.xy0) + } + } + + fun CompositionNode.transform(builder: TransformBuilder.() -> Unit) { + return this.transform(builder) + } + + /** + * Returns a deep copy of a [CompositionNode]. + * If [insert] is true the copy is inserted at [cursor]. + * @return a deep copy of the node + */ + // TODO: Include new features + fun CompositionNode.duplicate(insert: Boolean = true): CompositionNode { + fun nodeCopy(node: CompositionNode): CompositionNode { + val copy = when (node) { + is ImageNode -> { + ImageNode(node.image, node.x, node.y, node.width, node.height) + } + is ShapeNode -> { + ShapeNode(node.shape) + } + is TextNode -> { + TextNode(node.text, node.contour) + } + is GroupNode -> { + val children = node.children.map { nodeCopy(it) }.toMutableList() + val groupNode = GroupNode(children) + groupNode.children.forEach { + it.parent = groupNode + } + groupNode + } + } + copy.style = node.style + return copy + } + + val copy = nodeCopy(this) + if (insert) { + this@CompositionDrawer.cursor.children.add(copy) + copy.parent = cursor + } + return copy + } +} + +/** + * Creates a [Composition]. The draw operations contained inside + * the [drawFunction] do not render graphics to the screen, + * but populate the Composition instead. + */ +fun drawComposition( + documentBounds: CompositionDimensions = defaultCompositionDimensions, + composition: Composition? = null, + cursor: GroupNode? = composition?.root as? GroupNode, + drawFunction: CompositionDrawer.() -> Unit +): Composition = CompositionDrawer(documentBounds, composition, cursor).apply { drawFunction() }.composition + +/** + * Draw into an existing [Composition]. + */ +fun Composition.draw(drawFunction: CompositionDrawer.() -> Unit) { + drawComposition(composition = this, drawFunction = drawFunction) +} diff --git a/orx-composition/src/commonMain/kotlin/CompositionStyleSheet.kt b/orx-composition/src/commonMain/kotlin/CompositionStyleSheet.kt new file mode 100644 index 00000000..412a7ff1 --- /dev/null +++ b/orx-composition/src/commonMain/kotlin/CompositionStyleSheet.kt @@ -0,0 +1,430 @@ +@file:Suppress("RemoveExplicitTypeArguments") + +package org.openrndr.extra.composition +import org.openrndr.color.* +import org.openrndr.draw.* +import org.openrndr.extra.composition.AttributeOrPropertyKey.* +import org.openrndr.extra.composition.Inheritance.* +import org.openrndr.math.* +import org.openrndr.shape.Rectangle +import kotlin.reflect.* + +enum class Inheritance { + INHERIT, + RESET +} + +sealed interface AttributeOrPropertyValue { + val value: Any? + override fun toString(): String +} + +sealed interface Paint : AttributeOrPropertyValue { + override val value: ColorRGBa? + + class RGB(override val value: ColorRGBa) : Paint { + override fun toString(): String { + val hexs = listOf(value.r, value.g, value.b).map { + (it.coerceIn(0.0, 1.0) * 255.0).toInt().toString(16).padStart(2, '0') + } + return hexs.joinToString(prefix = "#", separator = "") + } + } + + // This one is kept just in case, it's not handled in any way yet + object CurrentColor : Paint { + override val value: ColorRGBa + get() = TODO("Not yet implemented") + + override fun toString(): String = "currentcolor" + } + + object None : Paint { + override val value: ColorRGBa? = null + override fun toString(): String = "none" + } +} + +sealed interface Shade : AttributeOrPropertyValue { + override val value: ShadeStyle + + class Value(override val value: ShadeStyle) : Shade { + override fun toString(): String = "" + } +} + +sealed interface Length : AttributeOrPropertyValue { + override val value: Double + + class Pixels(override val value: Double) : Length { + companion object { + fun fromInches(value: Double) = Pixels(value * 96.0) + fun fromPicas(value: Double) = Pixels(value * 16.0) + fun fromPoints(value: Double) = Pixels(value * (4.0 / 3.0)) + fun fromCentimeters(value: Double) = Pixels(value * (96.0 / 2.54)) + fun fromMillimeters(value: Double) = Pixels(value * (96.0 / 25.4)) + fun fromQuarterMillimeters(value: Double) = Pixels(value * (96.0 / 101.6)) + } + + override fun toString(): String = "$value" + } + + class Percent(override val value: Double) : Length { + override fun toString(): String { + return "${value}%" + } + } + + enum class UnitIdentifier { + IN, + PC, + PT, + PX, + CM, + MM, + Q + } +} + +inline val Double.pixels: Length.Pixels + get() = Length.Pixels(this) +inline val Double.percent: Length.Percent + get() = Length.Percent(this) + +sealed interface Numeric : AttributeOrPropertyValue { + override val value: Double + + class Rational(override val value: Double) : Numeric { + override fun toString(): String = "$value" + } +} + +sealed interface Transform : AttributeOrPropertyValue { + override val value: Matrix44 + + class Matrix(override val value: Matrix44) : Transform { + override fun toString(): String { + return if (value == Matrix44.IDENTITY) { + "" + } else { + "matrix(${value.c0r0} ${value.c0r1} " + + "${value.c1r0} ${value.c1r1} " + + "${value.c3r0} ${value.c3r1})" + } + } + } + + object None : Transform { + override val value = Matrix44.IDENTITY + override fun toString(): String = "" + } +} + +sealed interface Visibility : AttributeOrPropertyValue { + override val value: Boolean + + object Visible : Visibility { + override val value = true + override fun toString() = "visible" + } + + object Hidden : Visibility { + override val value = false + override fun toString() = "hidden" + } + + // This exists because the spec specifies so, + // it is effectively Hidden. + object Collapse : Visibility { + override val value = false + override fun toString() = "collapse" + } +} + +sealed interface Display : AttributeOrPropertyValue { + override val value: Boolean + + object Inline : Display { + override val value = true + override fun toString() = "inline" + } + + object Block : Display { + override val value = true + override fun toString() = "block" + } + + object None : Display { + override val value = false + override fun toString() = "none" + } +} + +sealed interface LineCap : AttributeOrPropertyValue { + override val value: org.openrndr.draw.LineCap + + object Round : LineCap { + override val value = org.openrndr.draw.LineCap.ROUND + override fun toString() = "round" + } + + object Butt : LineCap { + override val value = org.openrndr.draw.LineCap.BUTT + override fun toString() = "butt" + } + + object Square : LineCap { + override val value = org.openrndr.draw.LineCap.SQUARE + override fun toString() = "square" + } +} + +sealed interface LineJoin : AttributeOrPropertyValue { + override val value: org.openrndr.draw.LineJoin + + object Miter : LineJoin { + override val value = org.openrndr.draw.LineJoin.MITER + override fun toString() = "miter" + } + + object Bevel : LineJoin { + override val value = org.openrndr.draw.LineJoin.BEVEL + override fun toString() = "bevel" + } + + object Round : LineJoin { + override val value = org.openrndr.draw.LineJoin.ROUND + override fun toString() = "round" + } +} + +enum class Align { + NONE, + X_MIN_Y_MIN, + X_MID_Y_MIN, + X_MAX_Y_MIN, + X_MIN_Y_MID, + X_MID_Y_MID, + X_MAX_Y_MID, + X_MIN_Y_MAX, + X_MID_Y_MAX, + X_MAX_Y_MAX +} + +enum class MeetOrSlice { + MEET, + SLICE +} + +data class AspectRatio(val align: Align, val meetOrSlice: MeetOrSlice) : AttributeOrPropertyValue { + override val value = this + + companion object { + val DEFAULT = AspectRatio(Align.X_MID_Y_MID, MeetOrSlice.MEET) + } + + override fun toString(): String { + if (this == DEFAULT) { + return "" + } + + val a = when (align) { + Align.NONE -> "none" + Align.X_MIN_Y_MIN -> "xMinYMin" + Align.X_MID_Y_MIN -> "xMidYMin" + Align.X_MAX_Y_MIN -> "xMaxYMin" + Align.X_MIN_Y_MID -> "xMinYMid" + Align.X_MID_Y_MID -> "xMidYMid" + Align.X_MAX_Y_MID -> "xMaxYMid" + Align.X_MIN_Y_MAX -> "xMinYMax" + Align.X_MID_Y_MAX -> "xMidYMax" + Align.X_MAX_Y_MAX -> "xMaxYMax" + } + val m = when (meetOrSlice) { + MeetOrSlice.MEET -> "meet" + MeetOrSlice.SLICE -> "slice" + } + + return "$a $m" + } +} + +sealed interface ViewBox : AttributeOrPropertyValue { + override val value: Rectangle? + + class Value(override val value: Rectangle) : ViewBox { + override fun toString(): String = + "${value.x.toInt()} ${value.y.toInt()} ${value.width.toInt()} ${value.height.toInt()}" + } + + /** + * The viewBox has not been defined, + * **not** that it doesn't exist. + */ + object None : ViewBox { + override val value: Rectangle? = null + override fun toString(): String = "" + } +} + +private data class PropertyBehavior(val inherit: Inheritance, val initial: AttributeOrPropertyValue) + +private object PropertyBehaviors { + val behaviors = HashMap() +} + +private class PropertyDelegate( + val name: AttributeOrPropertyKey, + inheritance: Inheritance, + val initial: T +) { + init { + PropertyBehaviors.behaviors[name] = PropertyBehavior(inheritance, initial) + } + + @Suppress("UNCHECKED_CAST") + operator fun getValue(style: Styleable, property: KProperty<*>): T { + return (style[name] ?: PropertyBehaviors.behaviors[name]!!.initial) as T + } + + operator fun setValue(style: Styleable, property: KProperty<*>, value: T?) { + style[name] = value + } +} + +sealed class Styleable { + val properties = HashMap() + + operator fun get(name: AttributeOrPropertyKey) = properties[name] + + operator fun set(name: AttributeOrPropertyKey, value: AttributeOrPropertyValue?) { + properties[name] = value + } + + infix fun inherit(from: Style): Style { + return Style().also { + from.properties.forEach { (name, value) -> + if (PropertyBehaviors.behaviors[name]?.inherit == INHERIT) { + it.properties[name] = value + } + } + it.properties.putAll(properties) + } + } + + // Because AttributeOrPropertyValue has a toString override, + // we can abuse it for equality checks. + fun isInherited(from: Styleable, attributeKey: AttributeOrPropertyKey): Boolean = + when (this.properties[attributeKey].toString()) { + from.properties[attributeKey].toString() -> true + PropertyBehaviors.behaviors[attributeKey]?.initial.toString() -> true + else -> false + } +} + +class DocumentStyle : Styleable() +class Style : Styleable() + +var DocumentStyle.viewBox by PropertyDelegate(VIEW_BOX, RESET, ViewBox.None) +var DocumentStyle.preserveAspectRatio by PropertyDelegate( + PRESERVE_ASPECT_RATIO, + RESET, AspectRatio.DEFAULT +) + +var Style.stroke by PropertyDelegate(STROKE, INHERIT, Paint.None) +var Style.strokeOpacity by PropertyDelegate(STROKE_OPACITY, INHERIT, Numeric.Rational(1.0)) +var Style.strokeWeight by PropertyDelegate(STROKE_WIDTH, INHERIT, 1.0.pixels) +var Style.miterLimit by PropertyDelegate(STROKE_MITERLIMIT, INHERIT, Numeric.Rational(4.0)) +var Style.lineCap by PropertyDelegate(STROKE_LINECAP, INHERIT, LineCap.Butt) +var Style.lineJoin by PropertyDelegate(STROKE_LINEJOIN, INHERIT, LineJoin.Miter) + +var Style.fill by PropertyDelegate(FILL, INHERIT, Paint.RGB(ColorRGBa.BLACK)) +var Style.fillOpacity by PropertyDelegate(FILL_OPACITY, INHERIT, Numeric.Rational(1.0)) + +var Style.transform by PropertyDelegate(TRANSFORM, RESET, Transform.None) + +// Okay so the spec says `display` isn't inheritable, but effectively acts so +// when the element and its children are excluded from the rendering tree. +var Style.display by PropertyDelegate(DISPLAY, RESET, Display.Inline) +var Style.opacity by PropertyDelegate(OPACITY, RESET, Numeric.Rational(1.0)) +var Style.visibility by PropertyDelegate(VISIBILITY, INHERIT, Visibility.Visible) + +var Style.x by PropertyDelegate(X, RESET, 0.0.pixels) +var Style.y by PropertyDelegate(Y, RESET, 0.0.pixels) +var Style.width by PropertyDelegate(WIDTH, RESET, 768.0.pixels) +var Style.height by PropertyDelegate(HEIGHT, RESET, 576.0.pixels) + +var Style.shadeStyle by PropertyDelegate(SHADESTYLE, INHERIT, Shade.Value(ShadeStyle())) + +enum class AttributeOrPropertyKey { + // @formatter:off + // Attributes + BASE_PROFILE { override fun toString() = "baseProfile" }, + CLASS { override fun toString() = "class" }, + CX { override fun toString() = "cx" }, + CY { override fun toString() = "cy" }, + D { override fun toString() = "d" }, + DX { override fun toString() = "dx" }, + DY { override fun toString() = "dy" }, + GRADIENT_UNITS { override fun toString() = "gradientUnits" }, + HEIGHT { override fun toString() = "height" }, + ID { override fun toString() = "id" }, + OFFSET { override fun toString() = "offset" }, + PATH_LENGTH { override fun toString() = "pathLength" }, + POINTS { override fun toString() = "points" }, + PRESERVE_ASPECT_RATIO { override fun toString() = "preserveAspectRatio" }, + R { override fun toString() = "r" }, + ROTATE { override fun toString() = "rotate" }, + RX { override fun toString() = "rx" }, + RY { override fun toString() = "ry" }, + SPACE { override fun toString() = "xml:space" }, + STYLE { override fun toString() = "style" }, + TRANSFORM { override fun toString() = "transform" }, + VERSION { override fun toString() = "version" }, + VIEW_BOX { override fun toString() = "viewBox" }, + WIDTH { override fun toString() = "width" }, + X { override fun toString() = "x" }, + X1 { override fun toString() = "x1" }, + X2 { override fun toString() = "x2" }, + Y { override fun toString() = "y" }, + Y1 { override fun toString() = "y1" }, + Y2 { override fun toString() = "y2" }, + + // Properties + COLOR { override fun toString() = "color" }, + DIRECTION { override fun toString() = "direction" }, + DISPLAY { override fun toString() = "display" }, + DISPLAY_ALIGN { override fun toString() = "display-align" }, + FILL { override fun toString() = "fill" }, + FILL_OPACITY { override fun toString() = "fill-opacity" }, + FILL_RULE { override fun toString() = "fill-rule" }, + FONT_FAMILY { override fun toString() = "font-family" }, + FONT_SIZE { override fun toString() = "font-size" }, + FONT_STYLE { override fun toString() = "font-style" }, + FONT_VARIANT { override fun toString() = "font-variant" }, + FONT_WEIGHT { override fun toString() = "font-weight" }, + OPACITY { override fun toString() = "opacity" }, + STOP_COLOR { override fun toString() = "stop-color" }, + STOP_OPACITY { override fun toString() = "stop-opacity" }, + STROKE { override fun toString() = "stroke" }, + STROKE_DASHARRAY { override fun toString() = "stroke-dasharray" }, + STROKE_DASHOFFSET { override fun toString() = "stroke-dashoffset" }, + STROKE_LINECAP { override fun toString() = "stroke-linecap" }, + STROKE_LINEJOIN { override fun toString() = "stroke-linejoin" }, + STROKE_MITERLIMIT { override fun toString() = "stroke-miterlimit" }, + STROKE_OPACITY { override fun toString() = "stroke-opacity" }, + STROKE_WIDTH { override fun toString() = "stroke-width" }, + TEXT_ALIGN { override fun toString() = "text-align" }, + TEXT_ANCHOR { override fun toString() = "text-anchor" }, + UNICODE_BIDI { override fun toString() = "unicode-bidi" }, + VECTOR_EFFECT { override fun toString() = "vector-effect" }, + VISIBILITY { override fun toString() = "visibility" }, + + // Made-up properties + // because "Compositions aren't SVGs and yadda yadda" + // this one's for you, edwin + SHADESTYLE { override fun toString() = "" }; + + abstract override fun toString(): String + // @formatter:on +} \ No newline at end of file diff --git a/orx-composition/src/commonMain/kotlin/DrawerExtensions.kt b/orx-composition/src/commonMain/kotlin/DrawerExtensions.kt new file mode 100644 index 00000000..367f8d21 --- /dev/null +++ b/orx-composition/src/commonMain/kotlin/DrawerExtensions.kt @@ -0,0 +1,94 @@ +package org.openrndr.extra.composition + +import org.openrndr.draw.Drawer +import org.openrndr.shape.* + +/** + * Draws a [Composition] + * @param composition The composition to draw + * @see contour + * @see contours + * @see shape + * @see shapes + */ +fun Drawer.composition(composition: Composition) { + pushModel() + pushStyle() + + // viewBox transformation + model *= composition.calculateViewportTransform() + + fun node(compositionNode: CompositionNode) { + pushModel() + pushStyle() + model *= compositionNode.style.transform.value + + shadeStyle = (compositionNode.style.shadeStyle as Shade.Value).value + + when (compositionNode) { + is ShapeNode -> { + + compositionNode.style.stroke.let { + stroke = when (it) { + is Paint.RGB -> it.value.copy(alpha = 1.0) + Paint.None -> null + Paint.CurrentColor -> null + } + } + compositionNode.style.strokeOpacity.let { + stroke = when (it) { + is Numeric.Rational -> stroke?.opacify(it.value) + } + } + compositionNode.style.strokeWeight.let { + strokeWeight = when (it) { + is Length.Pixels -> it.value + is Length.Percent -> composition.normalizedDiagonalLength() * it.value / 100.0 + } + } + compositionNode.style.miterLimit.let { + miterLimit = when (it) { + is Numeric.Rational -> it.value + } + } + compositionNode.style.lineCap.let { + lineCap = it.value + } + compositionNode.style.lineJoin.let { + lineJoin = it.value + } + compositionNode.style.fill.let { + fill = when (it) { + is Paint.RGB -> it.value.copy(alpha = 1.0) + is Paint.None -> null + is Paint.CurrentColor -> null + } + } + compositionNode.style.fillOpacity.let { + fill = when (it) { + is Numeric.Rational -> fill?.opacify(it.value) + } + } + compositionNode.style.opacity.let { + when (it) { + is Numeric.Rational -> { + stroke = stroke?.opacify(it.value) + fill = fill?.opacify(it.value) + } + } + } + shape(compositionNode.shape) + } + is ImageNode -> { + image(compositionNode.image) + } + is TextNode -> TODO() + is GroupNode -> compositionNode.children.forEach { node(it) } + } + popModel() + popStyle() + } + node(composition.root) + popModel() + popStyle() +} \ No newline at end of file diff --git a/orx-composition/src/commonMain/kotlin/ProgramExtensions.kt b/orx-composition/src/commonMain/kotlin/ProgramExtensions.kt new file mode 100644 index 00000000..faa844f1 --- /dev/null +++ b/orx-composition/src/commonMain/kotlin/ProgramExtensions.kt @@ -0,0 +1,36 @@ +package org.openrndr.extra.composition + +import org.openrndr.Program +import org.openrndr.shape.Rectangle +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +// Derives Composition dimensions from current Drawer +@OptIn(ExperimentalContracts::class) +fun Program.drawComposition( + documentBounds: CompositionDimensions = CompositionDimensions(0.0.pixels, 0.0.pixels, this.drawer.width.toDouble().pixels, this.drawer.height.toDouble().pixels), + composition: Composition? = null, + cursor: GroupNode? = composition?.root as? GroupNode, + drawFunction: CompositionDrawer.() -> Unit +): Composition { + contract { + callsInPlace(drawFunction, InvocationKind.EXACTLY_ONCE) + } + return CompositionDrawer(documentBounds, composition, cursor).apply { drawFunction() }.composition +} + +@OptIn(ExperimentalContracts::class) +fun Program.drawComposition( + documentBounds: Rectangle, + composition: Composition? = null, + cursor: GroupNode? = composition?.root as? GroupNode, + drawFunction: CompositionDrawer.() -> Unit +): Composition { + contract { + callsInPlace(drawFunction, InvocationKind.EXACTLY_ONCE) + } + return CompositionDrawer(CompositionDimensions(documentBounds), composition, cursor).apply { drawFunction() }.composition +} + + diff --git a/orx-composition/src/commonTest/kotlin/TestComposition.kt b/orx-composition/src/commonTest/kotlin/TestComposition.kt new file mode 100644 index 00000000..21ab4391 --- /dev/null +++ b/orx-composition/src/commonTest/kotlin/TestComposition.kt @@ -0,0 +1,38 @@ +package org.openrndr.extra.composition + +import org.openrndr.shape.Shape +import kotlin.test.* + +class TestComposition { + val composition = let { _ -> + val root = GroupNode().also { it.id = "outer" } + root.children += GroupNode().also { + it.id = "inner" + } + root.children += ShapeNode(Shape.EMPTY).also { + it.id = "shape" + } + Composition(root) + } + + @Test + fun findGroup() { + assertEquals("outer", composition.findGroup("outer")?.id) + assertEquals("inner", composition.findGroup("inner")?.id) + assertNull(composition.findGroup("shape")) + } + + @Test + fun findShape() { + assertEquals("shape", composition.findShape("shape")?.id) + assertNull(composition.findShape("inner")) + assertNull(composition.findShape("outer")) + } + + @Test + fun findImage() { + assertNull(composition.findImage("inner")) + assertNull(composition.findImage("outer")) + assertNull(composition.findImage("shape")) + } +} \ No newline at end of file diff --git a/orx-svg/README.md b/orx-svg/README.md new file mode 100644 index 00000000..6647ccc0 --- /dev/null +++ b/orx-svg/README.md @@ -0,0 +1,5 @@ +# orx-svg + +SVG reader and writer library. + +This code was previously found in `openrndr-svg`. \ No newline at end of file diff --git a/orx-svg/build.gradle.kts b/orx-svg/build.gradle.kts new file mode 100644 index 00000000..ae9945cb --- /dev/null +++ b/orx-svg/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + org.openrndr.extra.convention.`kotlin-multiplatform` + alias(libs.plugins.kotest.multiplatform) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":orx-composition")) + implementation(libs.openrndr.shape) + } + } + + val jvmMain by getting { + dependencies { + implementation(libs.jsoup) + implementation(libs.openrndr.draw) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.kotest.assertions) + implementation(libs.kotest.framework.engine) + implementation(libs.kotlin.serialization.json) + runtimeOnly(libs.kotlin.reflect) + } + } + + val jvmDemo by getting { + dependencies { + implementation(project(":orx-svg")) + } + } + } +} + diff --git a/orx-svg/src/commonMain/kotlin/CSSColorNames.kt b/orx-svg/src/commonMain/kotlin/CSSColorNames.kt new file mode 100644 index 00000000..a9721fc0 --- /dev/null +++ b/orx-svg/src/commonMain/kotlin/CSSColorNames.kt @@ -0,0 +1,153 @@ +package org.openrndr.extra.svg + +/** Color name map as per the CSS Color Module Level 4 */ +internal val cssColorNames = mapOf( + "aliceblue" to 0xf0f8ff, + "antiquewhite" to 0xfaebd7, + "aqua" to 0x00ffff, + "aquamarine" to 0x7fffd4, + "azure" to 0xf0ffff, + "beige" to 0xf5f5dc, + "bisque" to 0xffe4c4, + "black" to 0x000000, + "blanchedalmond" to 0xffebcd, + "blue" to 0x0000ff, + "blueviolet" to 0x8a2be2, + "brown" to 0xa52a2a, + "burlywood" to 0xdeb887, + "cadetblue" to 0x5f9ea0, + "chartreuse" to 0x7fff00, + "chocolate" to 0xd2691e, + "coral" to 0xff7f50, + "cornflowerblue" to 0x6495ed, + "cornsilk" to 0xfff8dc, + "crimson" to 0xdc143c, + "cyan" to 0x00ffff, + "darkblue" to 0x00008b, + "darkcyan" to 0x008b8b, + "darkgoldenrod" to 0xb8860b, + "darkgray" to 0xa9a9a9, + "darkgrey" to 0xa9a9a9, + "darkgreen" to 0x006400, + "darkkhaki" to 0xbdb76b, + "darkmagenta" to 0x8b008b, + "darkolivegreen" to 0x556b2f, + "darkorange" to 0xff8c00, + "darkorchid" to 0x9932cc, + "darkred" to 0x8b0000, + "darksalmon" to 0xe9967a, + "darkseagreen" to 0x8fbc8f, + "darkslateblue" to 0x483d8b, + "darkslategray" to 0x2f4f4f, + "darkslategrey" to 0x2f4f4f, + "darkturquoise" to 0x00ced1, + "darkviolet" to 0x9400d3, + "deeppink" to 0xff1493, + "deepskyblue" to 0x00bfff, + "dimgray" to 0x696969, + "dimgrey" to 0x696969, + "dodgerblue" to 0x1e90ff, + "firebrick" to 0xb22222, + "floralwhite" to 0xfffaf0, + "forestgreen" to 0x228b22, + "fuchsia" to 0xff00ff, + "gainsboro" to 0xdcdcdc, + "ghostwhite" to 0xf8f8ff, + "gold" to 0xffd700, + "goldenrod" to 0xdaa520, + "gray" to 0x808080, + "grey" to 0x808080, + "green" to 0x008000, + "greenyellow" to 0xadff2f, + "honeydew" to 0xf0fff0, + "hotpink" to 0xff69b4, + "indianred" to 0xcd5c5c, + "indigo" to 0x4b0082, + "ivory" to 0xfffff0, + "khaki" to 0xf0e68c, + "lavender" to 0xe6e6fa, + "lavenderblush" to 0xfff0f5, + "lawngreen" to 0x7cfc00, + "lemonchiffon" to 0xfffacd, + "lightblue" to 0xadd8e6, + "lightcoral" to 0xf08080, + "lightcyan" to 0xe0ffff, + "lightgoldenrodyellow" to 0xfafad2, + "lightgray" to 0xd3d3d3, + "lightgrey" to 0xd3d3d3, + "lightgreen" to 0x90ee90, + "lightpink" to 0xffb6c1, + "lightsalmon" to 0xffa07a, + "lightseagreen" to 0x20b2aa, + "lightskyblue" to 0x87cefa, + "lightslategray" to 0x778899, + "lightslategrey" to 0x778899, + "lightsteelblue" to 0xb0c4de, + "lightyellow" to 0xffffe0, + "lime" to 0x00ff00, + "limegreen" to 0x32cd32, + "linen" to 0xfaf0e6, + "magenta" to 0xff00ff, + "maroon" to 0x800000, + "mediumaquamarine" to 0x66cdaa, + "mediumblue" to 0x0000cd, + "mediumorchid" to 0xba55d3, + "mediumpurple" to 0x9370d8, + "mediumseagreen" to 0x3cb371, + "mediumslateblue" to 0x7b68ee, + "mediumspringgreen" to 0x00fa9a, + "mediumturquoise" to 0x48d1cc, + "mediumvioletred" to 0xc71585, + "midnightblue" to 0x191970, + "mintcream" to 0xf5fffa, + "mistyrose" to 0xffe4e1, + "moccasin" to 0xffe4b5, + "navajowhite" to 0xffdead, + "navy" to 0x000080, + "oldlace" to 0xfdf5e6, + "olive" to 0x808000, + "olivedrab" to 0x6b8e23, + "orange" to 0xffa500, + "orangered" to 0xff4500, + "orchid" to 0xda70d6, + "palegoldenrod" to 0xeee8aa, + "palegreen" to 0x98fb98, + "paleturquoise" to 0xafeeee, + "palevioletred" to 0xd87093, + "papayawhip" to 0xffefd5, + "peachpuff" to 0xffdab9, + "peru" to 0xcd853f, + "pink" to 0xffc0cb, + "plum" to 0xdda0dd, + "powderblue" to 0xb0e0e6, + "purple" to 0x800080, + "rebeccapurple" to 0x663399, + "red" to 0xff0000, + "rosybrown" to 0xbc8f8f, + "royalblue" to 0x4169e1, + "saddlebrown" to 0x8b4513, + "salmon" to 0xfa8072, + "sandybrown" to 0xf4a460, + "seagreen" to 0x2e8b57, + "seashell" to 0xfff5ee, + "sienna" to 0xa0522d, + "silver" to 0xc0c0c0, + "skyblue" to 0x87ceeb, + "slateblue" to 0x6a5acd, + "slategray" to 0x708090, + "slategrey" to 0x708090, + "snow" to 0xfffafa, + "springgreen" to 0x00ff7f, + "steelblue" to 0x4682b4, + "tan" to 0xd2b48c, + "teal" to 0x008080, + "thistle" to 0xd8bfd8, + "tomato" to 0xff6347, + "turquoise" to 0x40e0d0, + "violet" to 0xee82ee, + "wheat" to 0xf5deb3, + "white" to 0xffffff, + "whitesmoke" to 0xf5f5f5, + "yellow" to 0xffff00, + "yellowgreen" to 0x9acd32 +) \ No newline at end of file diff --git a/orx-svg/src/commonMain/kotlin/SVGConstants.kt b/orx-svg/src/commonMain/kotlin/SVGConstants.kt new file mode 100644 index 00000000..83dc70aa --- /dev/null +++ b/orx-svg/src/commonMain/kotlin/SVGConstants.kt @@ -0,0 +1,147 @@ +package org.openrndr.extra.svg + +/** Element tag constants */ +internal object Tag { + const val CIRCLE = "circle" + const val DEFS = "defs" + const val ELLIPSE = "ellipse" + const val G = "g" + const val IMAGE = "image" + const val LINE = "line" + const val LINEAR_GRADIENT = "linearGradient" + const val PATH = "path" + const val POLYGON = "polygon" + const val POLYLINE = "polyline" + const val RADIAL_GRADIENT = "radialGradient" + const val RECT = "rect" + const val STOP = "stop" + const val SVG = "svg" + const val TBREAK = "tbreak" + const val TEXT = "text" + const val TEXT_AREA = "textArea" + const val TSPAN = "tspan" + const val USE = "use" + + val containerList = listOf( + DEFS, + G, + SVG, + USE + ) + + val graphicsList = listOf( + CIRCLE, + ELLIPSE, + IMAGE, + LINE, + PATH, + POLYGON, + POLYLINE, + RECT, + STOP, + TBREAK, + TEXT, + TEXT_AREA, + TSPAN + ) +} + +/** Attribute key constants */ +internal object Attr { + const val BASE_PROFILE = "baseProfile" + const val CLASS = "class" + const val CX = "cx" + const val CY = "cy" + const val D = "d" + const val DX = "dx" + const val DY = "dy" + const val GRADIENT_UNITS = "gradientUnits" + const val HEIGHT = "height" + const val ID = "id" + const val OFFSET = "offset" + const val PATH_LENGTH = "pathLength" + const val POINTS = "points" + const val PRESERVE_ASPECT_RATIO = "preserveAspectRatio" + const val R = "r" + const val ROTATE = "rotate" + const val RX = "rx" + const val RY = "ry" + const val SPACE = "xml:space" + const val STYLE = "style" + const val TRANSFORM = "transform" + const val VERSION = "version" + const val VIEW_BOX = "viewBox" + const val WIDTH = "width" + const val X = "x" + const val X1 = "x1" + const val X2 = "x2" + const val Y = "y" + const val Y1 = "y1" + const val Y2 = "y2" +} + +/** + * org.openrndr.shape.Property key constants + * These can also be defined in a style sheet/attribute + */ +internal object Prop { + const val COLOR = "color" + const val DIRECTION = "direction" + const val DISPLAY = "display" + const val DISPLAY_ALIGN = "display-align" + const val FILL = "fill" + const val FILL_OPACITY = "fill-opacity" + const val FILL_RULE = "fill-rule" + const val FONT_FAMILY = "font-family" + const val FONT_SIZE = "font-size" + const val FONT_STYLE = "font-style" + const val FONT_VARIANT = "font-variant" + const val FONT_WEIGHT = "font-weight" + const val OPACITY = "opacity" + const val STOP_COLOR = "stop-color" + const val STOP_OPACITY = "stop-opacity" + const val STROKE = "stroke" + const val STROKE_DASHARRAY = "stroke-dasharray" + const val STROKE_DASHOFFSET = "stroke-dashoffset" + const val STROKE_LINECAP = "stroke-linecap" + const val STROKE_LINEJOIN = "stroke-linejoin" + const val STROKE_MITERLIMIT = "stroke-miterlimit" + const val STROKE_OPACITY = "stroke-opacity" + const val STROKE_WIDTH = "stroke-width" + const val TEXT_ALIGN = "text-align" + const val TEXT_ANCHOR = "text-anchor" + const val UNICODE_BIDI = "unicode-bidi" + const val VECTOR_EFFECT = "vector-effect" + const val VISIBILITY = "visibility" + + val list = listOf( + COLOR, + DIRECTION, + DISPLAY, + DISPLAY_ALIGN, + FILL, + FILL_OPACITY, + FILL_RULE, + FONT_FAMILY, + FONT_SIZE, + FONT_STYLE, + FONT_VARIANT, + FONT_WEIGHT, + OPACITY, + STOP_COLOR, + STOP_OPACITY, + STROKE, + STROKE_DASHARRAY, + STROKE_DASHOFFSET, + STROKE_LINECAP, + STROKE_LINEJOIN, + STROKE_MITERLIMIT, + STROKE_OPACITY, + STROKE_WIDTH, + TEXT_ALIGN, + TEXT_ANCHOR, + UNICODE_BIDI, + VECTOR_EFFECT, + VISIBILITY + ) +} \ No newline at end of file diff --git a/orx-svg/src/commonMain/kotlin/ShapeExtensions.kt b/orx-svg/src/commonMain/kotlin/ShapeExtensions.kt new file mode 100644 index 00000000..ddaa8ef4 --- /dev/null +++ b/orx-svg/src/commonMain/kotlin/ShapeExtensions.kt @@ -0,0 +1,46 @@ +package org.openrndr.extra.svg + +import org.openrndr.shape.Shape +import org.openrndr.shape.ShapeContour + +fun Shape.toSvg(): String { + val sb = StringBuilder() + contours.forEach { + it.segments.forEachIndexed { index, segment -> + if (index == 0) { + sb.append("M ${segment.start.x} ${segment.start.y}") + } + sb.append( + when (segment.control.size) { + 1 -> "Q${segment.control[0].x} ${segment.control[0].y} ${segment.end.x} ${segment.end.y}" + 2 -> "C${segment.control[0].x} ${segment.control[0].y} ${segment.control[1].x} ${segment.control[1].y} ${segment.end.x} ${segment.end.y}" + else -> "L${segment.end.x} ${segment.end.y}" + } + ) + } + if (it.closed) { + sb.append("z") + } + } + return sb.toString() +} + +fun ShapeContour.toSvg(): String { + val sb = StringBuilder() + segments.forEachIndexed { index, segment -> + if (index == 0) { + sb.append("M ${segment.start.x} ${segment.start.y}") + } + sb.append( + when (segment.control.size) { + 1 -> "C${segment.control[0].x}, ${segment.control[0].y} ${segment.end.x} ${segment.end.y}" + 2 -> "C${segment.control[0].x}, ${segment.control[0].y} ${segment.control[1].x} ${segment.control[1].y} ${segment.end.x} ${segment.end.y}" + else -> "L${segment.end.x} ${segment.end.y}" + } + ) + } + if (closed) { + sb.append("z") + } + return sb.toString() +} diff --git a/orx-svg/src/jvmMain/kotlin/SVGElement.kt b/orx-svg/src/jvmMain/kotlin/SVGElement.kt new file mode 100644 index 00000000..16cd8e91 --- /dev/null +++ b/orx-svg/src/jvmMain/kotlin/SVGElement.kt @@ -0,0 +1,430 @@ +package org.openrndr.extra.svg + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.jsoup.nodes.* +import org.openrndr.extra.composition.* +import org.openrndr.math.* +import org.openrndr.shape.* + +private val logger = KotlinLogging.logger {} + +internal sealed class SVGElement(element: Element?) { + var tag: String = element?.tagName() ?: "" + var id: String = element?.id() ?: "" + + open var style = Style() + abstract fun handleAttribute(attribute: Attribute) + + // Any element can have a style attribute to pass down properties + fun styleProperty(key: String, value: String) { + when (key) { + Prop.STROKE -> style.stroke = SVGParse.color(value) + Prop.STROKE_OPACITY -> style.strokeOpacity = SVGParse.number(value) + Prop.STROKE_WIDTH -> style.strokeWeight = SVGParse.length(value) + Prop.STROKE_MITERLIMIT -> style.miterLimit = SVGParse.number(value) + Prop.STROKE_LINECAP -> style.lineCap = SVGParse.lineCap(value) + Prop.STROKE_LINEJOIN -> style.lineJoin = SVGParse.lineJoin(value) + Prop.FILL -> style.fill = SVGParse.color(value) + Prop.FILL_OPACITY -> style.fillOpacity = SVGParse.number(value) + Prop.OPACITY -> style.opacity = SVGParse.number(value) + else -> logger.warn { "Unknown property: $key" } + } + } + + /** Special case of parsing an inline style attribute. */ + fun inlineStyles(attribute: Attribute) { + attribute.value.split(";").forEach { + val result = it.split(":").map { s -> s.trim() } + + if (result.size >= 2) { + styleProperty(result[0], result[1]) + } + } + } +} + +/** element */ +internal class SVGSVGElement(element: Element) : SVGGroup(element) { + var documentStyle: DocumentStyle = DocumentStyle() + + init { + documentStyle.viewBox = SVGParse.viewBox(this.element) + documentStyle.preserveAspectRatio = SVGParse.preserveAspectRatio(this.element) + } + + var bounds = SVGParse.bounds(this.element) +} + +/** element but practically works with anything that has child elements */ +internal open class SVGGroup(val element: Element, val elements: MutableList = mutableListOf()) : + SVGElement(element) { + + init { + this.element.attributes().forEach { + if (it.key == Attr.STYLE) { + inlineStyles(it) + } else { + handleAttribute(it) + } + } + + handleChildren() + } + + private fun handleChildren() { + this.element.children().forEach { child -> + when (child.tagName()) { + in Tag.graphicsList -> elements.add(SVGPath(child)) + else -> elements.add(SVGGroup(child)) + } + } + } + + override fun handleAttribute(attribute: Attribute) { + when (attribute.key) { + // Attributes can also be style properties, in which case they're passed on + in Prop.list -> styleProperty(attribute.key, attribute.value) + Attr.TRANSFORM -> style.transform = SVGParse.transform(this.element) + } + } +} + +internal class Command(val op: String, vararg val operands: Double) { + fun asVectorList(): List? { + return if (operands.size % 2 == 0) { + operands.asList().chunked(2) { Vector2(it[0], it[1]) } + } else { + null + } + } +} + +// For evaluating elliptical arc arguments according to the SVG spec +internal fun Double.toBoolean(): Boolean? = when (this) { + 0.0 -> false + 1.0 -> true + else -> null +} + +internal class SVGPath(val element: Element? = null) : SVGElement(element) { + val commands = mutableListOf() + + private fun compounds(): List { + val compounds = mutableListOf() + val compoundIndices = mutableListOf() + + commands.forEachIndexed { index, it -> + if (it.op == "M" || it.op == "m") { + compoundIndices.add(index) + } + } + + compoundIndices.forEachIndexed { index, _ -> + val cs = compoundIndices[index] + val ce = if (index + 1 < compoundIndices.size) (compoundIndices[index + 1]) else commands.size + + // TODO: We shouldn't be making new SVGPaths without Elements to provide. + // Then we could make SVGPath's constructor non-nullable + val path = SVGPath() + path.commands.addAll(commands.subList(cs, ce)) + + compounds.add(path) + } + return compounds + } + + fun shape(): Shape { + var cursor = Vector2.ZERO + var anchor = Vector2.ZERO + // Still problematic + var prevCubicCtrlPoint: Vector2? = null + var prevQuadCtrlPoint: Vector2? = null + + val contours = compounds().map { compound -> + val segments = mutableListOf() + var closed = false + // If an argument is invalid, an error is logged, + // further interpreting is stopped and compound is returned as-is. + compound.commands.forEach { command -> + + if (command.op !in listOf("z", "Z") && command.operands.isEmpty()) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } + + val points = command.asVectorList() + + // TODO: Rethink this check + if (points == null && command.op.lowercase() !in listOf("a", "h", "v")) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } + + when (command.op) { + "A", "a" -> { + // If size == step, only the last window can be partial + // Special case as it also has boolean values + val contours = command.operands.toList().windowed(7, 7, true).map m@{ + if (it.size != 7) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val rx = it[0] + val ry = it[1] + val xAxisRot = it[2] + val largeArcFlag = it[3].toBoolean() + val sweepFlag = it[4].toBoolean() + + if (largeArcFlag == null || sweepFlag == null || rx == 0.0 || ry == 0.0) { + logger.error { "Invalid values provided for: ${command.op}" } + return@forEach + } + + val end = Vector2(it[5], it[6]).let { v -> + if (command.op == "a") { + v + cursor + } else { + v + } + } + + contour { + moveTo(cursor) + arcTo(rx, ry, xAxisRot, largeArcFlag, sweepFlag, end) + cursor = end + } + } + } + + // I don't know why we can't just have segments from the above map, + // but this is the only way this works. + segments += contours.flatMap { it.segments} + } + "M" -> { + // TODO: Log an error when this nulls + cursor = points!!.firstOrNull() ?: return@forEach + anchor = cursor + + // Following points are implicit lineto arguments + segments += points.drop(1).map { + Segment(cursor, it).apply { + cursor = it + } + } + } + "m" -> { + // TODO: Log an error when this nulls + cursor += points!!.firstOrNull() ?: return@forEach + anchor = cursor + + // Following points are implicit lineto arguments + segments += points.drop(1).map { + Segment(cursor, cursor + it).apply { + cursor += it + } + } + } + "L" -> { + segments += points!!.map { + Segment(cursor, it).apply { + cursor = it + } + } + } + "l" -> { + segments += points!!.map { + Segment(cursor, cursor + it).apply { + cursor += it + } + } + } + "H" -> { + segments += command.operands.map { + val target = Vector2(it, cursor.y) + Segment(cursor, target).apply { + cursor = target + } + } + } + "h" -> { + segments += command.operands.map { + val target = cursor + Vector2(it, 0.0) + Segment(cursor, target).apply { + cursor = target + } + } + } + "V" -> { + segments += command.operands.map { + val target = Vector2(cursor.x, it) + Segment(cursor, target).apply { + cursor = target + } + } + } + "v" -> { + segments += command.operands.map { + val target = cursor + Vector2(0.0, it) + Segment(cursor, target).apply { + cursor = target + } + } + } + "C" -> { + segments += points!!.windowed(3, 3, true).map { + if (it.size != 3) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val (cp1, cp2, target) = it + Segment(cursor, cp1, cp2, target).also { + cursor = target + prevCubicCtrlPoint = cp2 + } + } + } + } + "c" -> { + segments += points!!.windowed(3, 3, true).map { + if (it.size != 3) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val (cp1, cp2, target) = it.map { v -> cursor + v } + Segment(cursor, cp1, cp2, target).apply { + cursor = target + prevCubicCtrlPoint = cp2 + } + } + } + } + "S" -> { + segments += points!!.windowed(2, 2, true).map { + if (it.size != 2) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val cp1 = 2.0 * cursor - (prevCubicCtrlPoint ?: cursor) + val (cp2, target) = it + Segment(cursor, cp1, cp2, target).also { + cursor = target + prevCubicCtrlPoint = cp2 + } + } + } + } + "s" -> { + segments += points!!.windowed(2, 2, true).map { + if (it.size != 2) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val cp1 = 2.0 * cursor - (prevCubicCtrlPoint ?: cursor) + val (cp2, target) = it.map { v -> cursor + v } + Segment(cursor, cp1, cp2, target).also { + cursor = target + prevCubicCtrlPoint = cp2 + } + } + } + } + "Q" -> { + segments += points!!.windowed(2, 2, true).map { + if (it.size != 2) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val (cp, target) = it + Segment(cursor, cp, target).also { + cursor = target + prevQuadCtrlPoint = cp + } + } + } + } + "q" -> { + segments += points!!.windowed(2, 2, true).map { + if (it.size != 2) { + logger.error { "Invalid amount of arguments provided for: ${command.op}" } + return@forEach + } else { + val (cp, target) = it.map { v -> cursor + v } + Segment(cursor, cp, target).also { + cursor = target + prevQuadCtrlPoint = cp + } + } + } + } + "T" -> { + points!!.forEach { + val cp = 2.0 * cursor - (prevQuadCtrlPoint ?: cursor) + Segment(cursor, cp, it).also { _ -> + cursor = it + prevQuadCtrlPoint = cp + } + } + } + "t" -> { + points!!.forEach { + val cp = 2.0 * cursor - (prevQuadCtrlPoint ?: cursor) + Segment(cursor, cp, cursor + it).also { _ -> + cursor = it + prevQuadCtrlPoint = cp + } + } + } + "Z", "z" -> { + if ((cursor - anchor).length >= 0.001) { + segments += Segment(cursor, anchor) + } + cursor = anchor + closed = true + } + else -> { + // The spec declares we should still attempt to render + // the path up until the erroneous command as to visually + // signal the user where the error occurred. + logger.error { "Invalid path operator: ${command.op}" } + return@forEach + } + } + } + ShapeContour(segments, closed, YPolarity.CW_NEGATIVE_Y) + } + return Shape(contours) + } + + override fun handleAttribute(attribute: Attribute) { + if (this.element is Element) { + when (attribute.key) { + // Attributes can also be style properties, in which case they're passed on + in Prop.list -> styleProperty(attribute.key, attribute.value) + Attr.TRANSFORM -> style.transform = SVGParse.transform(this.element) + } + } + } + + init { + if (this.element is Element) { + commands += when (tag) { + Tag.PATH -> SVGParse.path(this.element) + Tag.LINE -> SVGParse.line(this.element) + Tag.RECT -> SVGParse.rectangle(this.element) + Tag.ELLIPSE -> SVGParse.ellipse(this.element) + Tag.CIRCLE -> SVGParse.circle(this.element) + Tag.POLYGON -> SVGParse.polygon(this.element) + Tag.POLYLINE -> SVGParse.polyline(this.element) + else -> emptyList() + } + + element.attributes().forEach { + if (it.key == Attr.STYLE) { + inlineStyles(it) + } else { + handleAttribute(it) + } + } + } + } +} \ No newline at end of file diff --git a/orx-svg/src/jvmMain/kotlin/SVGLoader.kt b/orx-svg/src/jvmMain/kotlin/SVGLoader.kt new file mode 100644 index 00000000..23f6041e --- /dev/null +++ b/orx-svg/src/jvmMain/kotlin/SVGLoader.kt @@ -0,0 +1,105 @@ +package org.openrndr.extra.svg +import org.jsoup.* +import org.jsoup.parser.* +import org.openrndr.extra.composition.* +import org.openrndr.shape.* +import java.io.* +import java.net.* + +/** + * Load a [Composition] from a filename, url or svg string + * @param fileOrUrlOrSvg a filename, a url or an svg document + */ +fun loadSVG(fileOrUrlOrSvg: String): Composition { + return if (fileOrUrlOrSvg.endsWith(".svg")) { + try { + val url = URL(fileOrUrlOrSvg) + parseSVG(url.readText()) + } catch (e: MalformedURLException) { + parseSVG(File(fileOrUrlOrSvg).readText()) + } + } else { + parseSVG(fileOrUrlOrSvg) + } +} + +/** + * Load a [Composition] from a file, url or svg string + * @param file a filename, a url or an svg document + */ +fun loadSVG(file: File): Composition { + return parseSVG(file.readText()) +} + +/** + * Parses an SVG document and creates a [Composition] + * @param svgString xml-like svg document + */ +fun parseSVG(svgString: String): Composition { + val document = SVGLoader().loadSVG(svgString) + return document.composition() +} + +// internal class SVGImage(val url: String, val x: Double?, val y: Double?, val width: Double?, val height: Double?) : SVGElement() + +internal class SVGDocument(private val root: SVGSVGElement, val namespaces: Map) { + fun composition(): Composition = Composition( + convertElement(root), + root.bounds + + ).apply { + namespaces.putAll(this@SVGDocument.namespaces) + this.documentStyle = this@SVGDocument.root.documentStyle + } + + private fun convertElement(svgElem: SVGElement): CompositionNode = when (svgElem) { + is SVGGroup -> GroupNode().apply { + this.id = svgElem.id + svgElem.elements.mapTo(children) { convertElement(it).also { x -> x.parent = this@apply } } + } + is SVGPath -> { + ShapeNode(svgElem.shape()).apply { + style = svgElem.style + this.id = svgElem.id + } + } + }.apply { + transform = svgElem.style.transform.value + } +} + +internal class SVGLoader { + fun loadSVG(svg: String): SVGDocument { + val doc = Jsoup.parse(svg, "", Parser.xmlParser()) + val root = doc.select(Tag.SVG).first() ?: error("no root") + val namespaces = root.attributes().filter { it.key.startsWith("xmlns") }.associate { + Pair(it.key, it.value) + } + val rootGroup = SVGSVGElement(root) + return SVGDocument(rootGroup, namespaces) + } + +// private fun handleImage(group: SVGGroup, e: Element) { +// val width = e.attr(Attr.WIDTH).toDoubleOrNull() +// val height = e.attr(Attr.HEIGHT).toDoubleOrNull() +// val x = e.attr("x").toDoubleOrNull() +// val y = e.attr("y").toDoubleOrNull() +// val imageData = e.attr("xlink:href") +// val image = ColorBuffer.fromUrl(imageData) +// val imageNode = ImageNode(image, width ?: image.width.toDouble(), height ?: image.height.toDouble()) +// val image = SVGImage(imageData, x, y, width, height) +// image.parseTransform(e) +// group.elements.add(image) +// } + +// private fun handleImage(group: SVGGroup, e: Element) { +// val width = e.attr("width").toDouble() +// val height = e.attr("height").toDouble() +// val url = e.attr("xlink:href") +// val image = SVGImage(url).apply { +// id = e.id() +// parseTransform(e) +// } +// image.id = e.id() +// } +} \ No newline at end of file diff --git a/orx-svg/src/jvmMain/kotlin/SVGParse.kt b/orx-svg/src/jvmMain/kotlin/SVGParse.kt new file mode 100644 index 00000000..dc8f095d --- /dev/null +++ b/orx-svg/src/jvmMain/kotlin/SVGParse.kt @@ -0,0 +1,498 @@ +package org.openrndr.extra.svg +import io.github.oshai.kotlinlogging.KotlinLogging +import org.jsoup.nodes.* +import org.openrndr.color.* +import org.openrndr.extra.composition.* +import org.openrndr.math.* +import org.openrndr.math.transforms.* +import org.openrndr.shape.* +import java.util.regex.* +import kotlin.math.* +import kotlin.text.MatchResult + +private val logger = KotlinLogging.logger {} + +internal sealed interface PropertyRegex { + + val regex: Regex + + companion object { + val wsp = "(?:\\s|\\A|\\Z)+".toRegex() + val commaWsp = "(?:\\s*,\\s*|\\s+)".toRegex() + const val align = "(?[xy](?:Min|Mid|Max)[XY](?:Min|Mid|Max))?" + const val meetOrSlice = "(?meet|slice)?" + const val unitIdentifier = "in|pc|pt|px|cm|mm|Q" + val opts = RegexOption.IGNORE_CASE + } + + object Any : PropertyRegex { + override val regex = ".+".toRegex() + } + + object Number : PropertyRegex { + override val regex = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:[eE][+-]?\\d+)?".toRegex() + } + + object NumberList : PropertyRegex { + override val regex = "(?:${Number.regex}$commaWsp${Number.regex}$commaWsp?)+".toRegex() + } + + object Length : PropertyRegex { + override val regex = "(?${Number.regex})(?$unitIdentifier)?".toRegex(opts) + } + + object Percentage : PropertyRegex { + override val regex = "${Number.regex}%".toRegex() + } + + object LengthOrPercentage : PropertyRegex { + override val regex = "${Length.regex}|${Percentage.regex}".toRegex(opts) + } + + object PreserveAspectRatio : PropertyRegex { + // We don't care for "defer", but if it's there, we'll ignore it. + override val regex = "$wsp(?:defer)?$wsp${align}$wsp${meetOrSlice}$wsp".toRegex(opts) + } + + object RGBHex : PropertyRegex { + override val regex = "#?([0-9a-f]{3,6})".toRegex(opts) + } + + object RGBFunctional : PropertyRegex { + // Matches rgb(255, 255, 255) + private val rgb8BitRegex = "(${Number.regex})${commaWsp}(${Number.regex})${commaWsp}(${Number.regex})" + + // Matches rgb(100%, 100%, 100%) + private val rgbPercentageRegex = "(${Number.regex})%${commaWsp}(${Number.regex})%${commaWsp}(${Number.regex})%" + + override val regex = "${wsp}rgb\\(\\s*(?>$rgb8BitRegex\\s*|\\s*$rgbPercentageRegex)\\s*\\)$wsp".toRegex(opts) + } +} + +internal object SVGParse { + fun viewBox(element: Element): ViewBox { + val viewBoxValue = element.attr(Attr.VIEW_BOX).trim() + val (minX, minY, width, height) = PropertyRegex.NumberList.regex.matches(viewBoxValue).let { + if (!it) { + return ViewBox.None + } + val list = viewBoxValue.split(PropertyRegex.commaWsp).map(String::toDouble) + when (list.size) { + // Early return and signal that the element should not be rendered at all + 1 -> if (list[0] == 0.0) { + return ViewBox.None + } else { + // Interpret as height + listOf(0.0, 0.0, 0.0, list[0]) + } + 2 -> listOf(0.0, 0.0, list[0], list[1]) + 3 -> listOf(0.0, list[0], list[1], list[2]) + 4 -> list + else -> return ViewBox.None + } + } + + return ViewBox.Value(Rectangle(minX, minY, width.coerceAtLeast(0.0), height.coerceAtLeast(0.0))) + } + + fun preserveAspectRatio(element: Element): AspectRatio { + val aspectRatioValue = element.attr(Attr.PRESERVE_ASPECT_RATIO) + + val (alignmentValue, meetValue) = PropertyRegex.PreserveAspectRatio.regex.matchEntire(aspectRatioValue).let { + val value = (it?.groups as? MatchNamedGroupCollection)?.get("align")?.value + val type = (it?.groups as? MatchNamedGroupCollection)?.get("meetOrSlice")?.value + + value to type + } + + val meet = when (meetValue) { + "slice" -> MeetOrSlice.SLICE + // Lacuna value + else -> MeetOrSlice.MEET + } + + return when (alignmentValue) { + "none" -> AspectRatio(Align.NONE, meet) + "xMinYMin" -> AspectRatio(Align.X_MIN_Y_MIN, meet) + "xMidYMin" -> AspectRatio(Align.X_MID_Y_MIN, meet) + "xMaxYMin" -> AspectRatio(Align.X_MAX_Y_MIN, meet) + "xMinYMid" -> AspectRatio(Align.X_MIN_Y_MID, meet) + "xMidYMid" -> AspectRatio(Align.X_MID_Y_MID, meet) + "xMaxYMid" -> AspectRatio(Align.X_MAX_Y_MID, meet) + "xMinYMax" -> AspectRatio(Align.X_MIN_Y_MAX, meet) + "xMidYMax" -> AspectRatio(Align.X_MID_Y_MAX, meet) + "xMaxYMax" -> AspectRatio(Align.X_MAX_Y_MAX, meet) + else -> AspectRatio(Align.X_MID_Y_MID, meet) + } + } + + fun bounds(element: Element): CompositionDimensions { + val values = listOf(Attr.X, Attr.Y, Attr.WIDTH, Attr.HEIGHT).map { attribute -> + element.attr(attribute).let { + it.ifEmpty { "0" } + } + } + + // There's no way this'll throw an OOB, right? + val (x, y, width, height) = values.map { str -> + PropertyRegex.Length.regex.matchEntire(str).let { + val value = (it?.groups as? MatchNamedGroupCollection)?.get("number")?.value?.toDouble() ?: 0.0 + val type = Length.UnitIdentifier.valueOf( + (it?.groups as? MatchNamedGroupCollection)?.get("ident")?.value?.uppercase() ?: "PX" + ) + + when (type) { + Length.UnitIdentifier.IN -> Length.Pixels.fromInches(value) + Length.UnitIdentifier.PC -> Length.Pixels.fromPicas(value) + Length.UnitIdentifier.PT -> Length.Pixels.fromPoints(value) + Length.UnitIdentifier.PX -> Length.Pixels(value) + Length.UnitIdentifier.CM -> Length.Pixels.fromCentimeters(value) + Length.UnitIdentifier.MM -> Length.Pixels.fromMillimeters(value) + Length.UnitIdentifier.Q -> Length.Pixels.fromQuarterMillimeters(value) + } + } + } + + return CompositionDimensions(x, y, width, height) + } + + fun lineJoin(value: String): LineJoin { + return when (value) { + "miter" -> LineJoin.Miter + "bevel" -> LineJoin.Bevel + "round" -> LineJoin.Round + else -> LineJoin.Miter + } + } + + fun lineCap(value: String): LineCap { + return when (value) { + "round" -> LineCap.Round + "butt" -> LineCap.Butt + "square" -> LineCap.Square + else -> LineCap.Butt + } + } + + fun number(value: String): Numeric { + return when (val match = PropertyRegex.Number.regex.matchEntire(value)) { + is MatchResult -> Numeric.Rational(match.groups[0]?.value?.toDouble() ?: 0.0) + else -> Numeric.Rational(0.0) + } + } + + fun length(value: String): Length { + val (number, ident) = PropertyRegex.Length.regex.matchEntire(value).let { + val number = (it?.groups as? MatchNamedGroupCollection)?.get("number")?.value?.toDouble() ?: 0.0 + val ident = Length.UnitIdentifier.valueOf( + (it?.groups as? MatchNamedGroupCollection)?.get("ident")?.value?.uppercase() ?: "PX" + ) + + number to ident + } + + return when (ident) { + Length.UnitIdentifier.IN -> Length.Pixels.fromInches(number) + Length.UnitIdentifier.PC -> Length.Pixels.fromPicas(number) + Length.UnitIdentifier.PT -> Length.Pixels.fromPoints(number) + Length.UnitIdentifier.PX -> Length.Pixels(number) + Length.UnitIdentifier.CM -> Length.Pixels.fromCentimeters(number) + Length.UnitIdentifier.MM -> Length.Pixels.fromMillimeters(number) + Length.UnitIdentifier.Q -> Length.Pixels.fromQuarterMillimeters(number) + } + } + + // Syntax should map to https://www.w3.org/TR/css-transforms-1/#svg-syntax + fun transform(element: Element): Transform { + var transform = Matrix44.IDENTITY + + val transformValue = element.attr(Attr.TRANSFORM).let { + it.ifEmpty { + return Transform.None + } + } + + // TODO: Number regex accepts `-` as a number lol + val p = Pattern.compile("(matrix|translate|scale|rotate|skewX|skewY)\\([\\d\\.,\\-\\s]+\\)") + val m = p.matcher(transformValue) + + // TODO: This looks to be making far too many assumptions about the well-formedness of its input + fun getTransformOperands(token: String): List { + val number = Pattern.compile("-?[0-9.eE\\-]+") + val nm = number.matcher(token) + val operands = mutableListOf() + while (nm.find()) { + val n = nm.group().toDouble() + operands.add(n) + } + return operands + } + while (m.find()) { + val token = m.group() + if (token.startsWith("matrix")) { + val operands = getTransformOperands(token) + val mat = Matrix44( + operands[0], operands[2], 0.0, operands[4], + operands[1], operands[3], 0.0, operands[5], + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ) + transform *= mat + } + if (token.startsWith("scale")) { + val operands = getTransformOperands(token.substring(5)) + val mat = Matrix44.scale(operands[0], operands.elementAtOrElse(1) { operands[0] }, 0.0) + transform *= mat + } + if (token.startsWith("translate")) { + val operands = getTransformOperands(token.substring(9)) + val mat = Matrix44.translate(operands[0], operands.elementAtOrElse(1) { 0.0 }, 0.0) + transform *= mat + } + if (token.startsWith("rotate")) { + val operands = getTransformOperands(token.substring(6)) + val angle = Math.toRadians(operands[0]) + val sina = sin(angle) + val cosa = cos(angle) + val x = operands.elementAtOrElse(1) { 0.0 } + val y = operands.elementAtOrElse(2) { 0.0 } + val mat = Matrix44( + cosa, -sina, 0.0, -x * cosa + y * sina + x, + sina, cosa, 0.0, -x * sina - y * cosa + y, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ) + transform *= mat + } + if (token.startsWith("skewX")) { + val operands = getTransformOperands(token.substring(5)) + val mat = Matrix44( + 1.0, tan(Math.toRadians(operands[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 + ) + transform *= mat + } + if (token.startsWith("skewY")) { + val operands = getTransformOperands(token.substring(5)) + val mat = Matrix44( + 1.0, 0.0, 0.0, 0.0, + tan(Math.toRadians(operands[0])), 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ) + transform *= mat + } + } + + return if (transform != Matrix44.IDENTITY) { + Transform.Matrix(transform) + } else { + Transform.None + } + } + + /** Assumes [numbers] consists of at least 2 elements. */ + private fun pointsToCommands(numbers: List): List { + val commands = mutableListOf(Command("M", numbers[0], numbers[1])) + numbers.drop(2).windowed(2, 2, false).mapTo(commands) { (x, y) -> + Command("L", x, y) + } + return commands + } + + fun polygon(element: Element): List { + val commands = polyline(element).toMutableList() + commands.add(Command("Z")) + return commands + } + + fun polyline(element: Element): List { + val numbers = element.attr(Attr.POINTS) + .trim() + .split(PropertyRegex.commaWsp) + .takeWhile(PropertyRegex.Number.regex::matches) + .map(String::toDouble) + return if (numbers.size > 1) { + if (numbers.size and 1 == 1) { + logger.warn { "${element.tagName()} attribute ${Attr.POINTS} has odd amount of numbers" } + pointsToCommands(numbers.dropLast(1)) + } else { + pointsToCommands(numbers) + } + } else { + emptyList() + } + } + + private fun ellipsePath(x: Double, y: Double, width: Double, height: Double): List { + val dx = x - width / 2 + val dy = y - height / 2 + + val kappa = 0.5522848 + // control point offset horizontal + val ox = width / 2 * kappa + // control point offset vertical + val oy = height / 2 * kappa + // x-end + val xe = dx + width + // y-end + val ye = dy + height + + return listOf( + Command("M", dx, y), + Command("C", dx, y - oy, x - ox, dy, x, dy), + Command("C", x + ox, dy, xe, y - oy, xe, y), + Command("C", xe, y + oy, x + ox, ye, x, ye), + Command("C", x - ox, ye, dx, y + oy, dx, y), + Command("z") + ) + } + + fun circle(element: Element): List { + val cx = element.attr(Attr.CX).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull() + } ?: return emptyList() + val cy = element.attr(Attr.CY).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull() + } ?: return emptyList() + val r = element.attr(Attr.R).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0) + } ?: return emptyList() + + return ellipsePath(cx, cy, r, r) + } + + fun ellipse(element: Element): List { + val cx = element.attr(Attr.CX).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull() + } ?: return emptyList() + val cy = element.attr(Attr.CY).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull() + } ?: return emptyList() + val rx = element.attr(Attr.RX).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0) + } ?: return emptyList() + val ry = element.attr(Attr.RY).let { + if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0) + } ?: return emptyList() + + return ellipsePath(cx, cy, rx, ry) + } + + fun rectangle(element: Element): List { + val x = element.attr(Attr.X).let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() } ?: return emptyList() + val y = element.attr(Attr.Y).let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() } ?: return emptyList() + val width = element.attr(Attr.WIDTH).toDoubleOrNull() ?: return emptyList() + val height = element.attr(Attr.HEIGHT).toDoubleOrNull() ?: return emptyList() + + return listOf( + Command("M", x, y), + Command("h", width), + Command("v", height), + Command("h", -width), + Command("z") + ) + } + + fun line(element: Element): List { + val x1 = element.attr(Attr.X1).toDoubleOrNull() ?: return emptyList() + val x2 = element.attr(Attr.X2).toDoubleOrNull() ?: return emptyList() + val y1 = element.attr(Attr.Y1).toDoubleOrNull() ?: return emptyList() + val y2 = element.attr(Attr.Y2).toDoubleOrNull() ?: return emptyList() + + return listOf( + Command("M", x1, y1), + Command("L", x2, y2) + ) + } + + fun path(element: Element): List { + val pathValue = element.attr(Attr.D) + + if (pathValue.trim() == "none") { + return emptyList() + } + + val rawCommands = pathValue.split("(?=[MmZzLlHhVvCcSsQqTtAa])".toRegex()).map(String::trim) + val numbers = Pattern.compile("[-+]?[0-9]*[.]?[0-9]+(?:[eE][-+]?[0-9]+)?") + val commands = mutableListOf() + + for (rawCommand in rawCommands) { + if (rawCommand.isNotEmpty()) { + val numberMatcher = numbers.matcher(rawCommand) + val operands = mutableListOf() + while (numberMatcher.find()) { + operands.add(numberMatcher.group().toDouble()) + } + commands += Command(rawCommand[0].toString(), *(operands.toDoubleArray())) + } + } + + return commands + } + + fun color(colorValue: String): Paint { + val col = colorValue.lowercase() + + return when { + col.isEmpty() -> Paint.None + col.startsWith("#") -> { + val normalizedColor = normalizeColorHex(col) ?: return Paint.None + val v = normalizedColor.toLong(radix = 16) + val vi = v.toInt() + val r = vi shr 16 and 0xff + val g = vi shr 8 and 0xff + val b = vi and 0xff + Paint.RGB(ColorRGBa(r / 255.0, g / 255.0, b / 255.0, linearity = Linearity.SRGB)) + } + col.startsWith("rgb(") -> rgbFunction(col) + col in cssColorNames -> Paint.RGB(ColorRGBa.fromHex(cssColorNames[col]!!)) + else -> Paint.None + } + } + + private fun normalizeColorHex(colorHex: String): String? { + val matchResult = PropertyRegex.RGBHex.regex.matchEntire(colorHex) ?: return null + + val hexValue = matchResult.groups[1]!!.value.lowercase() + val normalizedArgb = when (hexValue.length) { + 3 -> expandToTwoDigitsPerComponent("f$hexValue") + 6 -> hexValue + else -> return null + } + + return normalizedArgb + } + + /** + * Parses rgb functional notation as described in CSS2 spec + */ + private fun rgbFunction(rgbValue: String): Paint { + + val result = + PropertyRegex.RGBFunctional.regex.matchEntire(rgbValue) ?: return Paint.None + + // The first three capture groups contain values if the match was without percentages + // Otherwise the values are in capture groups #4 to #6. + // Based on this information, we can deduce the divisor. + val divisor = if (result.groups[1] == null) { + 100.0 + } else { + 255.0 + } + + // Drop full match, filter out empty matches, map it, deconstruct it + val (r, g, b) = result.groupValues + .drop(1) + .filter(String::isNotBlank) + .map { it.toDouble().coerceIn(0.0..divisor) / divisor } + return Paint.RGB(ColorRGBa(r, g, b, linearity = Linearity.SRGB)) + } + + private fun expandToTwoDigitsPerComponent(hexValue: String) = + hexValue.asSequence() + .map { "$it$it" } + .reduce(String::plus) +} \ No newline at end of file diff --git a/orx-svg/src/jvmMain/kotlin/SVGWriter.kt b/orx-svg/src/jvmMain/kotlin/SVGWriter.kt new file mode 100644 index 00000000..58613ee1 --- /dev/null +++ b/orx-svg/src/jvmMain/kotlin/SVGWriter.kt @@ -0,0 +1,151 @@ +package org.openrndr.extra.svg + +import org.jsoup.nodes.* +import org.openrndr.extra.composition.* +import org.openrndr.extra.composition.TextNode + +import java.io.* + +fun Composition.saveToFile(file: File) { + if (file.extension == "svg") { + val svg = writeSVG(this) + file.writeText(svg) + } else { + throw IllegalArgumentException("can only write svg files, the extension '${file.extension}' is not supported") + } +} + +fun Composition.toSVG() = writeSVG(this) + +private val CompositionNode.svgId: String + get() = when (val tempId = id) { + "" -> "" + null -> "" + else -> "id=\"$tempId\"" + } + +private val CompositionNode.svgAttributes: String + get() { + return attributes.map { + if (it.value != null && it.value != "") { + "${it.key}=\"${Entities.escape(it.value ?: "")}\"" + } else { + it.key + } + }.joinToString(" ") + } + +private fun Styleable.serialize(parentStyleable: Styleable? = null): String { + val sb = StringBuilder() + + val filtered = this.properties.filter { + it.key != AttributeOrPropertyKey.SHADESTYLE + } + // Inheritance can't be checked without a parentStyleable + when (parentStyleable) { + null -> filtered.forEach { (t, u) -> + if (u.toString().isNotEmpty()) { + sb.append("$t=\"${u.toString()}\" ") + } + } + else -> filtered.forEach { (t, u) -> + if (u.toString().isNotEmpty() && !this.isInherited(parentStyleable, t)) { + sb.append("$t=\"${u.toString()}\" ") + } + } + + } + + return sb.trim().toString() +} + +fun writeSVG( + composition: Composition, + topLevelId: String = "openrndr-svg" +): String { + val sb = StringBuilder() + sb.append("\n") + + val defaultNamespaces = mapOf( + "xmlns" to "http://www.w3.org/2000/svg", + "xmlns:xlink" to "http://www.w3.org/1999/xlink" + ) + + val namespaces = (defaultNamespaces + composition.namespaces).map { (k, v) -> + "$k=\"$v\"" + }.joinToString(" ") + + val styleSer = composition.style.serialize() + val docStyleSer = composition.documentStyle.serialize() + + sb.append("") + + var textPathID = 0 + process(composition.root) { stage -> + if (stage == VisitStage.PRE) { + + val styleSerialized = this.style.serialize(this.parent?.style) + + when (this) { + is GroupNode -> { + val attributes = listOf(svgId, styleSerialized, svgAttributes) + .filter(String::isNotEmpty) + .joinToString(" ") + sb.append("\n") + } + is ShapeNode -> { + val pathAttribute = "d=\"${shape.toSvg()}\"" + + val attributes = listOf( + svgId, + styleSerialized, + svgAttributes, + pathAttribute + ) + .filter(String::isNotEmpty) + .joinToString(" ") + + sb.append("\n") + } + + is TextNode -> { + val contour = this.contour + val escapedText = Entities.escape(this.text) + if (contour == null) { + sb.append("$escapedText") + } else { + sb.append("") + sb.append("") + sb.append("") + sb.append("$escapedText") + textPathID++ + } + } + is ImageNode -> { + val dataUrl = this.image.toDataUrl() + sb.append("""""") + } + } + } else { + if (this is GroupNode) { + sb.append("\n") + } + } + } + sb.append("") + return sb.toString() +} + + +private enum class VisitStage { + PRE, + POST +} + +private fun process(compositionNode: CompositionNode, visitor: CompositionNode.(stage: VisitStage) -> Unit) { + compositionNode.visitor(VisitStage.PRE) + if (compositionNode is GroupNode) { + compositionNode.children.forEach { process(it, visitor) } + } + compositionNode.visitor(VisitStage.POST) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 452fe95f..95867cf3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ include( "orx-camera", "orx-jvm:orx-chataigne", "orx-color", + "orx-composition", "orx-compositor", "orx-compute-graph", "orx-compute-graph-nodes", @@ -67,6 +68,7 @@ include( "orx-shader-phrases", "orx-shade-styles", "orx-shapes", + "orx-svg", "orx-jvm:orx-syphon", "orx-temporal-blur", "orx-jvm:orx-tensorflow",