[orx-composition, orx-svg] Move Composition and SVG code from OPENRNDR to ORX

This commit is contained in:
Edwin Jakobs
2024-03-15 13:34:17 +01:00
parent e35037fbec
commit 8eeb74e1a8
19 changed files with 3558 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# orx-composition
Shape composition library
This code was previously part of `openrndr-draw`.

View 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"))
}
}
}
}

View 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
}
}

View 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)
}

View 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
}

View 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()
}

View 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
}

View 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"))
}
}