commonMain.Composition.kt Maven / Gradle / Ivy
package org.openrndr.extra.composition
import org.openrndr.draw.*
import org.openrndr.math.*
import org.openrndr.math.transforms.*
import org.openrndr.shape.Rectangle
import org.openrndr.shape.Shape
import org.openrndr.shape.ShapeContour
import org.openrndr.shape.bounds
import kotlin.math.*
import kotlin.reflect.*
/**
* Describes a node in a composition
*/
sealed class CompositionNode {
var id: String? = null
var parent: CompositionNode? = null
/** This CompositionNode's own style. */
var style: Style = Style()
/**
* This CompositionNode's computed style.
* Where every style attribute is obtained by
* overwriting the Style in the following order:
* 1. Default style attributes.
* 2. Parent Node's computed style's inheritable attributes.
* 3. This Node's own style attributes.
*/
val effectiveStyle: Style
get() = when (val p = parent) {
is CompositionNode -> style inherit p.effectiveStyle
else -> style
}
/**
* Custom attributes to be applied to the Node in addition to the Style attributes.
*/
var attributes = mutableMapOf()
/**
* a map that stores user data
*/
val userData = mutableMapOf()
/**
* a [Rectangle] that describes the bounding box of the contents
*/
abstract val bounds: Rectangle
val effectiveStroke get() = effectiveStyle.stroke.value
val effectiveStrokeOpacity get() = effectiveStyle.strokeOpacity.value
val effectiveStrokeWeight get() = effectiveStyle.strokeWeight.value
val effectiveMiterLimit get() = effectiveStyle.miterLimit.value
val effectiveLineCap get() = effectiveStyle.lineCap.value
val effectiveLineJoin get() = effectiveStyle.lineJoin.value
val effectiveFill get() = effectiveStyle.fill.value
val effectiveFillOpacity get() = effectiveStyle.fillOpacity.value
val effectiveDisplay get() = effectiveStyle.display.value
val effectiveOpacity get() = effectiveStyle.opacity.value
val effectiveVisibility get() = effectiveStyle.visibility.value
val effectiveShadeStyle get() = effectiveStyle.shadeStyle.value
/** Calculates the absolute transformation of the current node. */
val effectiveTransform: Matrix44
get() = when (val p = parent) {
is CompositionNode -> transform * p.effectiveTransform
else -> transform
}
var stroke
get() = style.stroke.value
set(value) {
style.stroke = when (value) {
null -> Paint.None
else -> Paint.RGB(value)
}
}
var strokeOpacity
get() = style.strokeOpacity.value
set(value) {
style.strokeOpacity = Numeric.Rational(value)
}
var strokeWeight
get() = style.strokeWeight.value
set(value) {
style.strokeWeight = Length.Pixels(value)
}
var miterLimit
get() = style.miterLimit.value
set(value) {
style.miterLimit = Numeric.Rational(value)
}
var lineCap
get() = style.lineCap.value
set(value) {
style.lineCap = when (value) {
org.openrndr.draw.LineCap.BUTT -> LineCap.Butt
org.openrndr.draw.LineCap.ROUND -> LineCap.Round
org.openrndr.draw.LineCap.SQUARE -> LineCap.Square
}
}
var lineJoin
get() = style.lineJoin.value
set(value) {
style.lineJoin = when (value) {
org.openrndr.draw.LineJoin.BEVEL -> LineJoin.Bevel
org.openrndr.draw.LineJoin.MITER -> LineJoin.Miter
org.openrndr.draw.LineJoin.ROUND -> LineJoin.Round
}
}
var fill
get() = style.fill.value
set(value) {
style.fill = when (value) {
null -> Paint.None
else -> Paint.RGB(value)
}
}
var fillOpacity
get() = style.fillOpacity.value
set(value) {
style.fillOpacity = Numeric.Rational(value)
}
var opacity
get() = style.opacity.value
set(value) {
style.opacity = Numeric.Rational(value)
}
var shadeStyle
get() = style.shadeStyle.value
set(value) {
style.shadeStyle = Shade.Value(value)
}
var transform
get() = style.transform.value
set(value) {
style.transform = Transform.Matrix(value)
}
}
// TODO: Deprecate this?
operator fun KMutableProperty0.setValue(thisRef: Style, property: KProperty<*>, value: ShadeStyle) {
this.set(Shade.Value(value))
}
fun transform(node: CompositionNode): Matrix44 =
(node.parent?.let { transform(it) } ?: Matrix44.IDENTITY) * node.transform
/**
* a [CompositionNode] that holds a single image [ColorBuffer]
*/
class ImageNode(var image: ColorBuffer, var x: Double, var y: Double, var width: Double, var height: Double) :
CompositionNode() {
override val bounds: Rectangle
get() = Rectangle(0.0, 0.0, width, height).contour.transform(transform(this)).bounds
}
/**
* a [CompositionNode] that holds a single [Shape]
*/
class ShapeNode(var shape: Shape) : CompositionNode() {
override val bounds: Rectangle
get() {
val t = effectiveTransform
return if (t === Matrix44.IDENTITY) {
shape.bounds
} else {
shape.bounds.contour.transform(t).bounds
}
}
/**
* apply transforms of all ancestor nodes and return a new detached org.openrndr.shape.ShapeNode with conflated transform
*/
fun conflate(): ShapeNode {
return ShapeNode(shape).also {
it.id = id
it.parent = parent
it.style = style
it.transform = transform(this)
it.attributes = attributes
}
}
/**
* apply transforms of all ancestor nodes and return a new detached shape node with identity transform and transformed Shape
* @param composition use viewport transform
*/
fun flatten(composition: Composition? = null): ShapeNode {
val viewport = composition?.calculateViewportTransform() ?: Matrix44.IDENTITY
return ShapeNode(shape.transform(viewport * transform(this))).also {
it.id = id
it.parent = parent
it.style = effectiveStyle
it.attributes = attributes
}
}
fun copy(
id: String? = this.id,
parent: CompositionNode? = null,
style: Style = this.style,
attributes: MutableMap = this.attributes,
shape: Shape = this.shape
): ShapeNode {
return ShapeNode(shape).also {
it.id = id
it.parent = parent
it.style = style
it.attributes = attributes
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ShapeNode) return false
if (shape != other.shape) return false
return true
}
override fun hashCode(): Int {
return shape.hashCode()
}
/**
* the local [Shape] with the [effectiveTransform] applied to it
*/
val effectiveShape
get() = shape.transform(effectiveTransform)
}
/**
* a [CompositionNode] that holds a single text
*/
data class TextNode(var text: String, var contour: ShapeContour?) : CompositionNode() {
// TODO: This should not be Rectangle.EMPTY
override val bounds: Rectangle
get() = Rectangle.EMPTY
}
/**
* A [CompositionNode] that functions as a group node
*/
open class GroupNode(open val children: MutableList = mutableListOf()) : CompositionNode() {
override val bounds: Rectangle
get() {
return children.map { it.bounds }.bounds
}
fun copy(
id: String? = this.id,
parent: CompositionNode? = null,
style: Style = this.style,
children: MutableList = this.children
): GroupNode {
return GroupNode(children).also {
it.id = id
it.parent = parent
it.style = style
it.attributes = attributes
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is GroupNode) return false
if (children != other.children) return false
return true
}
override fun hashCode(): Int {
return children.hashCode()
}
}
data class CompositionDimensions(val x: Length, val y: Length, val width: Length, val height: Length) {
val position = Vector2((x as Length.Pixels).value, (y as Length.Pixels).value)
val dimensions = Vector2((width as Length.Pixels).value, (height as Length.Pixels).value)
constructor(rectangle: Rectangle) : this(
rectangle.corner.x.pixels,
rectangle.corner.y.pixels,
rectangle.dimensions.x.pixels,
rectangle.dimensions.y.pixels
)
override fun toString(): String = "$x $y $width $height"
// I'm not entirely sure why this is needed but
// but otherwise equality checks will never succeed
override fun equals(other: Any?): Boolean {
return other is CompositionDimensions
&& x.value == other.x.value
&& y.value == other.y.value
&& width.value == other.width.value
&& height.value == other.height.value
}
override fun hashCode(): Int {
var result = x.hashCode()
result = 31 * result + y.hashCode()
result = 31 * result + width.hashCode()
result = 31 * result + height.hashCode()
return result
}
}
val defaultCompositionDimensions = CompositionDimensions(0.0.pixels, 0.0.pixels, 768.0.pixels, 576.0.pixels)
class GroupNodeStop(children: MutableList) : GroupNode(children)
/**
* A vector composition.
* @param root the root node of the composition
* @param bounds the dimensions of the composition
*/
class Composition(val root: CompositionNode, var bounds: CompositionDimensions = defaultCompositionDimensions) {
constructor(root: CompositionNode, bounds: Rectangle) : this(root, CompositionDimensions(bounds))
/** SVG/XML namespaces */
val namespaces = mutableMapOf()
var style: Style = Style()
/**
* The style attributes affecting the whole document, such as the viewBox area and aspect ratio.
*/
var documentStyle: DocumentStyle = DocumentStyle()
init {
val (x, y, width, height) = bounds
style.x = x
style.y = y
style.width = width
style.height = height
}
fun findShapes() = root.findShapes()
fun findShape(id: String): ShapeNode? {
return (root.find { it is ShapeNode && it.id == id }) as? ShapeNode
}
fun findImages() = root.findImages()
fun findImage(id: String): ImageNode? {
return (root.find { it is ImageNode && it.id == id }) as? ImageNode
}
fun findGroups(): List = root.findGroups()
fun findGroup(id: String): GroupNode? {
return (root.find { it is GroupNode && it.id == id }) as? GroupNode
}
fun clear() = (root as? GroupNode)?.children?.clear()
/** Calculates the equivalent of `1%` in pixels. */
internal fun normalizedDiagonalLength(): Double = sqrt(bounds.dimensions.squaredLength / 2.0)
/**
* Calculates effective viewport transformation using [viewBox] and [preserveAspectRatio].
* As per [the SVG 2.0 spec](https://svgwg.org/svg2-draft/single-page.html#coords-ComputingAViewportsTransform).
*/
fun calculateViewportTransform(): Matrix44 {
return when (documentStyle.viewBox) {
ViewBox.None -> Matrix44.IDENTITY
is ViewBox.Value -> {
when (val vb = (documentStyle.viewBox as ViewBox.Value).value) {
Rectangle.EMPTY -> {
// The intent is to not display the element
Matrix44.ZERO
}
else -> {
val vbCorner = vb.corner
val vbDims = vb.dimensions
val eCorner = bounds.position
val eDims = bounds.dimensions
val (align, meetOrSlice) = documentStyle.preserveAspectRatio
val scale = (eDims / vbDims).let {
if (align != Align.NONE) {
if (meetOrSlice == MeetOrSlice.MEET) {
Vector2(min(it.x, it.y))
} else {
Vector2(max(it.x, it.y))
}
} else {
it
}
}
val translate = (eCorner - (vbCorner * scale)).let {
val cx = eDims.x - vbDims.x * scale.x
val cy = eDims.y - vbDims.y * scale.y
it + when (align) {
// TODO: This first one probably doesn't comply with the spec
Align.NONE -> Vector2.ZERO
Align.X_MIN_Y_MIN -> Vector2.ZERO
Align.X_MID_Y_MIN -> Vector2(cx / 2, 0.0)
Align.X_MAX_Y_MIN -> Vector2(cx, 0.0)
Align.X_MIN_Y_MID -> Vector2(0.0, cy / 2)
Align.X_MID_Y_MID -> Vector2(cx / 2, cy / 2)
Align.X_MAX_Y_MID -> Vector2(cx, cy / 2)
Align.X_MIN_Y_MAX -> Vector2(0.0, cy)
Align.X_MID_Y_MAX -> Vector2(cx / 2, cy)
Align.X_MAX_Y_MAX -> Vector2(cx, cy)
}
}
buildTransform {
translate(translate)
scale(scale.x, scale.y, 1.0)
}
}
}
}
}
}
}
/**
* remove node from its parent [CompositionNode]
*/
fun CompositionNode.remove() {
require(parent != null) { "parent is null" }
val parentGroup = (parent as? GroupNode)
if (parentGroup != null) {
val filtered = parentGroup.children.filter {
it != this
}
parentGroup.children.clear()
parentGroup.children.addAll(filtered)
}
parent = null
}
fun CompositionNode.findTerminals(filter: (CompositionNode) -> Boolean): List {
val result = mutableListOf()
fun find(node: CompositionNode) {
when (node) {
is GroupNode -> node.children.forEach { find(it) }
else -> if (filter(node)) {
result.add(node)
}
}
}
find(this)
return result
}
fun CompositionNode.findAll(filter: (CompositionNode) -> Boolean): List {
val result = mutableListOf()
fun find(node: CompositionNode) {
if (filter(node)) {
result.add(node)
}
if (node is GroupNode) {
node.children.forEach { find(it) }
}
}
find(this)
return result
}
/**
* Finds first [CompositionNode] to match the given [predicate].
*/
fun CompositionNode.find(predicate: (CompositionNode) -> Boolean): CompositionNode? {
if (predicate(this)) {
return this
} else if (this is GroupNode) {
val deque: ArrayDeque = ArrayDeque(children)
while (deque.isNotEmpty()) {
val node = deque.removeFirst()
if (predicate(node)) {
return node
} else if (node is GroupNode) {
deque.addAll(node.children)
}
}
}
return null
}
/**
* find all descendant [ShapeNode] nodes, including potentially this node
* @return a [List] of [ShapeNode] nodes
*/
fun CompositionNode.findShapes(): List = findTerminals { it is ShapeNode }.map { it as ShapeNode }
/**
* find all descendant [ImageNode] nodes, including potentially this node
* @return a [List] of [ImageNode] nodes
*/
fun CompositionNode.findImages(): List = findTerminals { it is ImageNode }.map { it as ImageNode }
/**
* find all descendant [GroupNode] nodes, including potentially this node
* @return a [List] of [GroupNode] nodes
*/
fun CompositionNode.findGroups(): List = findAll { it is GroupNode }.map { it as GroupNode }
/**
* visit this [CompositionNode] and all descendant nodes and execute [visitor]
*/
fun CompositionNode.visitAll(visitor: (CompositionNode.() -> Unit)) {
visitor()
if (this is GroupNode) {
for (child in children) {
child.visitAll(visitor)
}
}
}
/**
* org.openrndr.shape.UserData delegate
*/
class UserData(
val name: String, val initial: T
) {
@Suppress("UNCHECKED_CAST")
operator fun getValue(node: CompositionNode, property: KProperty<*>): T {
val value: T? = node.userData[name] as? T
return value ?: initial
}
operator fun setValue(stylesheet: CompositionNode, property: KProperty<*>, value: T) {
stylesheet.userData[name] = value
}
}
fun CompositionNode.filter(filter: (CompositionNode) -> Boolean): CompositionNode? {
val f = filter(this)
if (!f) {
return null
}
if (this is GroupNode) {
val copies = mutableListOf()
children.forEach {
val filtered = it.filter(filter)
if (filtered != null) {
when (filtered) {
is ShapeNode -> {
copies.add(filtered.copy(parent = this))
}
is GroupNode -> {
copies.add(filtered.copy(parent = this))
}
else -> {
}
}
}
}
return GroupNode(children = copies)
} else {
return this
}
}
fun CompositionNode.map(mapper: (CompositionNode) -> CompositionNode): CompositionNode {
val r = mapper(this)
return when (r) {
is GroupNodeStop -> {
r.copy().also { copy ->
copy.children.forEach {
it.parent = copy
}
}
}
is GroupNode -> {
val copy = r.copy(children = r.children.map { it.map(mapper) }.toMutableList())
copy.children.forEach {
it.parent = copy
}
copy
}
else -> r
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy