[orx-composition, orx-svg] Move Composition and SVG code from OPENRNDR to ORX
This commit is contained in:
@@ -28,6 +28,7 @@ zxing = "3.5.3"
|
|||||||
ktor = "2.3.9"
|
ktor = "2.3.9"
|
||||||
jgit = "6.9.0.202403050737-r"
|
jgit = "6.9.0.202403050737-r"
|
||||||
javaosc = "0.8"
|
javaosc = "0.8"
|
||||||
|
jsoup = "1.17.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "kotlinLogging" }
|
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-core = { group = "org.antlr", name = "antlr4", version.ref = "antlr" }
|
||||||
antlr-runtime = { group = "org.antlr", name = "antlr4-runtime", 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" }
|
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-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" }
|
jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" }
|
||||||
|
|||||||
5
orx-composition/README.md
Normal file
5
orx-composition/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# orx-composition
|
||||||
|
|
||||||
|
Shape composition library
|
||||||
|
|
||||||
|
This code was previously part of `openrndr-draw`.
|
||||||
29
orx-composition/build.gradle.kts
Normal file
29
orx-composition/build.gradle.kts
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
589
orx-composition/src/commonMain/kotlin/Composition.kt
Normal file
589
orx-composition/src/commonMain/kotlin/Composition.kt
Normal file
@@ -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<String, String?>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a map that stores user data
|
||||||
|
*/
|
||||||
|
val userData = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Shade>.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<String, String?> = 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<CompositionNode> = 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<CompositionNode> = 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<CompositionNode>) : 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<String, String>()
|
||||||
|
|
||||||
|
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<GroupNode> = 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<CompositionNode> {
|
||||||
|
val result = mutableListOf<CompositionNode>()
|
||||||
|
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<CompositionNode> {
|
||||||
|
val result = mutableListOf<CompositionNode>()
|
||||||
|
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<CompositionNode> = 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<ShapeNode> = 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<ImageNode> = 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<GroupNode> = 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<T : Any>(
|
||||||
|
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<CompositionNode>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
760
orx-composition/src/commonMain/kotlin/CompositionDrawer.kt
Normal file
760
orx-composition/src/commonMain/kotlin/CompositionDrawer.kt
Normal file
@@ -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<ShapeNodeIntersection>.merge(threshold: Double = 0.5): List<ShapeNodeIntersection> {
|
||||||
|
val result = mutableListOf<ShapeNodeIntersection>()
|
||||||
|
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<Matrix44>()
|
||||||
|
private val styleStack = ArrayDeque<CompositionDrawStyle>().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<ShapeContour>, 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<ShapeNodeNearestContour> {
|
||||||
|
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<ShapeNodeNearestContour> = 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<ShapeNodeIntersection> {
|
||||||
|
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<ShapeNodeIntersection> {
|
||||||
|
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<CompositionNode>()
|
||||||
|
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<Shape>, 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<Rectangle>, insert: Boolean = true) = rectangles.map { rectangle(it, insert) }
|
||||||
|
|
||||||
|
fun rectangles(positions: List<Vector2>, width: Double, height: Double, insert: Boolean = true) = rectangles(positions.map {
|
||||||
|
Rectangle(it, width, height)
|
||||||
|
}, insert)
|
||||||
|
|
||||||
|
fun rectangles(positions: List<Vector2>, dimensions: List<Vector2>, 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<Circle>, insert: Boolean = true) = circles.map { circle(it, insert) }
|
||||||
|
|
||||||
|
fun circles(positions: List<Vector2>, radius: Double, insert: Boolean = true) = circles(positions.map {
|
||||||
|
Circle(
|
||||||
|
it,
|
||||||
|
radius
|
||||||
|
)
|
||||||
|
}, insert)
|
||||||
|
|
||||||
|
fun circles(positions: List<Vector2>, radii: List<Double>, 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<LineSegment>,
|
||||||
|
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<Segment>,
|
||||||
|
insert: Boolean = true
|
||||||
|
) = segments.map {
|
||||||
|
segment(it, insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lineStrip(
|
||||||
|
points: List<Vector2>,
|
||||||
|
insert: Boolean = true
|
||||||
|
) = contour(ShapeContour.fromPoints(points, false, YPolarity.CW_NEGATIVE_Y), insert)
|
||||||
|
|
||||||
|
fun lineLoop(
|
||||||
|
points: List<Vector2>,
|
||||||
|
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<String>, positions: List<Vector2>) =
|
||||||
|
(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)
|
||||||
|
}
|
||||||
430
orx-composition/src/commonMain/kotlin/CompositionStyleSheet.kt
Normal file
430
orx-composition/src/commonMain/kotlin/CompositionStyleSheet.kt
Normal file
@@ -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<AttributeOrPropertyKey, PropertyBehavior>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PropertyDelegate<T : AttributeOrPropertyValue>(
|
||||||
|
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<AttributeOrPropertyKey, AttributeOrPropertyValue?>()
|
||||||
|
|
||||||
|
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<ViewBox>(VIEW_BOX, RESET, ViewBox.None)
|
||||||
|
var DocumentStyle.preserveAspectRatio by PropertyDelegate<AspectRatio>(
|
||||||
|
PRESERVE_ASPECT_RATIO,
|
||||||
|
RESET, AspectRatio.DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
|
var Style.stroke by PropertyDelegate<Paint>(STROKE, INHERIT, Paint.None)
|
||||||
|
var Style.strokeOpacity by PropertyDelegate<Numeric>(STROKE_OPACITY, INHERIT, Numeric.Rational(1.0))
|
||||||
|
var Style.strokeWeight by PropertyDelegate<Length>(STROKE_WIDTH, INHERIT, 1.0.pixels)
|
||||||
|
var Style.miterLimit by PropertyDelegate<Numeric>(STROKE_MITERLIMIT, INHERIT, Numeric.Rational(4.0))
|
||||||
|
var Style.lineCap by PropertyDelegate<LineCap>(STROKE_LINECAP, INHERIT, LineCap.Butt)
|
||||||
|
var Style.lineJoin by PropertyDelegate<LineJoin>(STROKE_LINEJOIN, INHERIT, LineJoin.Miter)
|
||||||
|
|
||||||
|
var Style.fill by PropertyDelegate<Paint>(FILL, INHERIT, Paint.RGB(ColorRGBa.BLACK))
|
||||||
|
var Style.fillOpacity by PropertyDelegate<Numeric>(FILL_OPACITY, INHERIT, Numeric.Rational(1.0))
|
||||||
|
|
||||||
|
var Style.transform by PropertyDelegate<Transform>(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>(DISPLAY, RESET, Display.Inline)
|
||||||
|
var Style.opacity by PropertyDelegate<Numeric>(OPACITY, RESET, Numeric.Rational(1.0))
|
||||||
|
var Style.visibility by PropertyDelegate<Visibility>(VISIBILITY, INHERIT, Visibility.Visible)
|
||||||
|
|
||||||
|
var Style.x by PropertyDelegate<Length>(X, RESET, 0.0.pixels)
|
||||||
|
var Style.y by PropertyDelegate<Length>(Y, RESET, 0.0.pixels)
|
||||||
|
var Style.width by PropertyDelegate<Length>(WIDTH, RESET, 768.0.pixels)
|
||||||
|
var Style.height by PropertyDelegate<Length>(HEIGHT, RESET, 576.0.pixels)
|
||||||
|
|
||||||
|
var Style.shadeStyle by PropertyDelegate<Shade>(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
|
||||||
|
}
|
||||||
94
orx-composition/src/commonMain/kotlin/DrawerExtensions.kt
Normal file
94
orx-composition/src/commonMain/kotlin/DrawerExtensions.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
36
orx-composition/src/commonMain/kotlin/ProgramExtensions.kt
Normal file
36
orx-composition/src/commonMain/kotlin/ProgramExtensions.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
38
orx-composition/src/commonTest/kotlin/TestComposition.kt
Normal file
38
orx-composition/src/commonTest/kotlin/TestComposition.kt
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
orx-svg/README.md
Normal file
5
orx-svg/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# orx-svg
|
||||||
|
|
||||||
|
SVG reader and writer library.
|
||||||
|
|
||||||
|
This code was previously found in `openrndr-svg`.
|
||||||
38
orx-svg/build.gradle.kts
Normal file
38
orx-svg/build.gradle.kts
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
153
orx-svg/src/commonMain/kotlin/CSSColorNames.kt
Normal file
153
orx-svg/src/commonMain/kotlin/CSSColorNames.kt
Normal file
@@ -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
|
||||||
|
)
|
||||||
147
orx-svg/src/commonMain/kotlin/SVGConstants.kt
Normal file
147
orx-svg/src/commonMain/kotlin/SVGConstants.kt
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
46
orx-svg/src/commonMain/kotlin/ShapeExtensions.kt
Normal file
46
orx-svg/src/commonMain/kotlin/ShapeExtensions.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
430
orx-svg/src/jvmMain/kotlin/SVGElement.kt
Normal file
430
orx-svg/src/jvmMain/kotlin/SVGElement.kt
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <svg> 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <g> element but practically works with anything that has child elements */
|
||||||
|
internal open class SVGGroup(val element: Element, val elements: MutableList<SVGElement> = 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<Vector2>? {
|
||||||
|
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<Command>()
|
||||||
|
|
||||||
|
private fun compounds(): List<SVGPath> {
|
||||||
|
val compounds = mutableListOf<SVGPath>()
|
||||||
|
val compoundIndices = mutableListOf<Int>()
|
||||||
|
|
||||||
|
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<Segment>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
orx-svg/src/jvmMain/kotlin/SVGLoader.kt
Normal file
105
orx-svg/src/jvmMain/kotlin/SVGLoader.kt
Normal file
@@ -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<String, String>) {
|
||||||
|
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()
|
||||||
|
// }
|
||||||
|
}
|
||||||
498
orx-svg/src/jvmMain/kotlin/SVGParse.kt
Normal file
498
orx-svg/src/jvmMain/kotlin/SVGParse.kt
Normal file
@@ -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 = "(?<align>[xy](?:Min|Mid|Max)[XY](?:Min|Mid|Max))?"
|
||||||
|
const val meetOrSlice = "(?<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>${Number.regex})(?<ident>$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<Double> {
|
||||||
|
val number = Pattern.compile("-?[0-9.eE\\-]+")
|
||||||
|
val nm = number.matcher(token)
|
||||||
|
val operands = mutableListOf<Double>()
|
||||||
|
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<Double>): List<Command> {
|
||||||
|
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<Command> {
|
||||||
|
val commands = polyline(element).toMutableList()
|
||||||
|
commands.add(Command("Z"))
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
fun polyline(element: Element): List<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command> {
|
||||||
|
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<Command>()
|
||||||
|
|
||||||
|
for (rawCommand in rawCommands) {
|
||||||
|
if (rawCommand.isNotEmpty()) {
|
||||||
|
val numberMatcher = numbers.matcher(rawCommand)
|
||||||
|
val operands = mutableListOf<Double>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
151
orx-svg/src/jvmMain/kotlin/SVGWriter.kt
Normal file
151
orx-svg/src/jvmMain/kotlin/SVGWriter.kt
Normal file
@@ -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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\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("<svg version=\"1.2\" baseProfile=\"tiny\" id=\"$topLevelId\" $namespaces $styleSer $docStyleSer>")
|
||||||
|
|
||||||
|
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("<g${" $attributes"}>\n")
|
||||||
|
}
|
||||||
|
is ShapeNode -> {
|
||||||
|
val pathAttribute = "d=\"${shape.toSvg()}\""
|
||||||
|
|
||||||
|
val attributes = listOf(
|
||||||
|
svgId,
|
||||||
|
styleSerialized,
|
||||||
|
svgAttributes,
|
||||||
|
pathAttribute
|
||||||
|
)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.joinToString(" ")
|
||||||
|
|
||||||
|
sb.append("<path $attributes/>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
is TextNode -> {
|
||||||
|
val contour = this.contour
|
||||||
|
val escapedText = Entities.escape(this.text)
|
||||||
|
if (contour == null) {
|
||||||
|
sb.append("<text $svgId $svgAttributes>$escapedText</text>")
|
||||||
|
} else {
|
||||||
|
sb.append("<defs>")
|
||||||
|
sb.append("<path id=\"text$textPathID\" d=\"${contour.toSvg()}\"/>")
|
||||||
|
sb.append("</defs>")
|
||||||
|
sb.append("<text $styleSerialized><textPath href=\"#text$textPathID\">$escapedText</textPath></text>")
|
||||||
|
textPathID++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ImageNode -> {
|
||||||
|
val dataUrl = this.image.toDataUrl()
|
||||||
|
sb.append("""<image xlink:href="$dataUrl" height="${this.image.height}" width="${this.image.width}"/>""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this is GroupNode) {
|
||||||
|
sb.append("</g>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("</svg>")
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ include(
|
|||||||
"orx-camera",
|
"orx-camera",
|
||||||
"orx-jvm:orx-chataigne",
|
"orx-jvm:orx-chataigne",
|
||||||
"orx-color",
|
"orx-color",
|
||||||
|
"orx-composition",
|
||||||
"orx-compositor",
|
"orx-compositor",
|
||||||
"orx-compute-graph",
|
"orx-compute-graph",
|
||||||
"orx-compute-graph-nodes",
|
"orx-compute-graph-nodes",
|
||||||
@@ -67,6 +68,7 @@ include(
|
|||||||
"orx-shader-phrases",
|
"orx-shader-phrases",
|
||||||
"orx-shade-styles",
|
"orx-shade-styles",
|
||||||
"orx-shapes",
|
"orx-shapes",
|
||||||
|
"orx-svg",
|
||||||
"orx-jvm:orx-syphon",
|
"orx-jvm:orx-syphon",
|
||||||
"orx-temporal-blur",
|
"orx-temporal-blur",
|
||||||
"orx-jvm:orx-tensorflow",
|
"orx-jvm:orx-tensorflow",
|
||||||
|
|||||||
Reference in New Issue
Block a user