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