package org.openrndr.extra.composition import org.openrndr.collections.pflatMap import org.openrndr.collections.pforEach import org.openrndr.color.ColorRGBa import org.openrndr.draw.ColorBuffer import org.openrndr.draw.LineCap import org.openrndr.draw.LineJoin import org.openrndr.math.Matrix44 import org.openrndr.math.Vector2 import org.openrndr.math.Vector3 import org.openrndr.math.YPolarity import org.openrndr.math.transforms.* import org.openrndr.shape.* /** * Used internally to define [ClipMode]s. */ enum class ClipOp { DISABLED, DIFFERENCE, REVERSE_DIFFERENCE, INTERSECT, UNION } /** * Specifies if transformations should be kept separate * or applied to the clipped object and reset to identity. */ enum class TransformMode { KEEP, APPLY } /** * Specifies in which way to combine [Shape]s * to form a [Composition] */ enum class ClipMode(val grouped: Boolean, val op: ClipOp) { DISABLED(false, ClipOp.DISABLED), DIFFERENCE(false, ClipOp.DIFFERENCE), DIFFERENCE_GROUP(true, ClipOp.DIFFERENCE), REVERSE_DIFFERENCE(false, ClipOp.REVERSE_DIFFERENCE), REVERSE_DIFFERENCE_GROUP(true, ClipOp.REVERSE_DIFFERENCE), INTERSECT(false, ClipOp.INTERSECT), INTERSECT_GROUP(true, ClipOp.INTERSECT), UNION(false, ClipOp.UNION), UNION_GROUP(true, ClipOp.UNION) } /** * The set of draw style properties used for rendering a [Composition] */ private data class CompositionDrawStyle( var fill: ColorRGBa? = null, var fillOpacity: Double = 1.0, var stroke: ColorRGBa? = ColorRGBa.BLACK, var strokeOpacity: Double = 1.0, var strokeWeight: Double = 1.0, var opacity: Double = 1.0, var clipMode: ClipMode = ClipMode.DISABLED, var mask: Shape? = null, var transformMode: TransformMode = TransformMode.APPLY, var lineCap: LineCap = LineCap.BUTT, var lineJoin: LineJoin = LineJoin.MITER, var miterlimit: Double = 4.0, var visibility: Visibility = Visibility.Visible ) /** * Data structure containing intersection information. */ data class ShapeNodeIntersection(val node: ShapeNode, val intersection: ContourIntersection) /** * Data structure containing information about a point * in a [ShapeContour] closest to some other 2D point. */ data class ShapeNodeNearestContour(val node: ShapeNode, val point: ContourPoint, val distanceDirection: Vector2, val distance: Double) /** * Merges two lists of [ShapeNodeIntersection] removing duplicates under the * given [threshold]. Used internally by [intersections]. */ fun List.merge(threshold: Double = 0.5): List { val result = mutableListOf() for (i in this) { val nearest = result.minByOrNull { it.intersection.position.squaredDistanceTo(i.intersection.position) } if (nearest == null) { result.add(i) } else if (nearest.intersection.position.squaredDistanceTo(i.intersection.position) >= threshold * threshold) { result.add(i) } } return result } /** * A Drawer-like interface for the creation of Compositions * This should be easier than creating Compositions manually */ class CompositionDrawer(documentBounds: CompositionDimensions = defaultCompositionDimensions, composition: Composition? = null, cursor: GroupNode? = composition?.root as? GroupNode ) { val root = (composition?.root as? GroupNode) ?: GroupNode() val composition = composition ?: Composition(root, documentBounds) var cursor = cursor ?: root private set private val modelStack = ArrayDeque() private val styleStack = ArrayDeque().apply { } private var drawStyle = CompositionDrawStyle() var model = Matrix44.IDENTITY var fill get() = drawStyle.fill?.opacify(drawStyle.fillOpacity)?.opacify(drawStyle.opacity) set(value) = run { drawStyle.fill = value?.copy(alpha = 1.0) drawStyle.fillOpacity = value?.alpha ?: 1.0 } var fillOpacity get() = drawStyle.fillOpacity set(value) = run { drawStyle.fillOpacity = value } var stroke get() = drawStyle.stroke?.opacify(drawStyle.strokeOpacity)?.opacify(drawStyle.opacity) set(value) = run { drawStyle.stroke = value?.copy(alpha = 1.0) drawStyle.strokeOpacity = value?.alpha ?: 1.0 } var strokeOpacity get() = drawStyle.strokeOpacity set(value) = run { drawStyle.strokeOpacity = value } var strokeWeight get() = drawStyle.strokeWeight set(value) = run { drawStyle.strokeWeight = value } var miterlimit get() = drawStyle.miterlimit set(value) = run { drawStyle.miterlimit = value } var lineCap get() = drawStyle.lineCap set(value) = run { drawStyle.lineCap = value } var lineJoin get() = drawStyle.lineJoin set(value) = run { drawStyle.lineJoin = value } var opacity get() = drawStyle.opacity set(value) = run { drawStyle.opacity = value } var visibility get() = drawStyle.visibility set(value) = run { drawStyle.visibility = value } var clipMode get() = drawStyle.clipMode set(value) = run { drawStyle.clipMode = value } var mask: Shape? get() = drawStyle.mask set(value) = run { drawStyle.mask = value } var transformMode get() = drawStyle.transformMode set(value) = run { drawStyle.transformMode = value } fun pushModel() { modelStack.addLast(model) } fun popModel() { model = modelStack.removeLast() } fun pushStyle() { styleStack.addLast(drawStyle.copy()) } fun popStyle() { drawStyle = styleStack.removeLast() } fun isolated(draw: CompositionDrawer.() -> Unit) { pushModel() pushStyle() draw() popModel() popStyle() } fun GroupNode.with(builder: CompositionDrawer.() -> Unit): GroupNode { val oldCursor = cursor cursor = this builder() cursor = oldCursor return this } /** * Create a group node and run `builder` inside its context * @param insert if true the created group will be inserted at [cursor] * @param id an optional identifier * @param builder the function that is executed inside the group context */ fun group(insert: Boolean = true, id: String? = null, builder: CompositionDrawer.() -> Unit): GroupNode { val group = GroupNode() group.id = id val oldCursor = cursor if (insert) { cursor.children.add(group) group.parent = cursor } cursor = group builder() cursor = oldCursor return group } fun translate(x: Double, y: Double) = translate(Vector2(x, y)) fun rotate(rotationInDegrees: Double) { model *= Matrix44.rotateZ(rotationInDegrees) } fun scale(s: Double) { model *= Matrix44.scale(s, s, s) } fun scale(x: Double, y: Double) { model *= Matrix44.scale(x, y, 1.0) } fun translate(t: Vector2) { model *= Matrix44.translate(t.vector3()) } fun contour(contour: ShapeContour, insert: Boolean = true): ShapeNode? { if (contour.empty) { return null } val shape = Shape(listOf(contour)) return shape(shape, insert) } fun contours(contours: List, insert: Boolean = true) = contours.map { contour(it, insert) } /** * Search for a point on a contour in the composition tree that's nearest to `point` * @param point the query point * @param searchFrom a node from which the search starts, defaults to composition root * @return an optional org.openrndr.shape.ShapeNodeNearestContour instance */ fun nearest( point: Vector2, searchFrom: CompositionNode = composition.root as GroupNode ): ShapeNodeNearestContour? { return distances(point, searchFrom).firstOrNull() } fun CompositionNode.nearest(point: Vector2) = nearest(point, searchFrom = this) fun difference( shape: Shape, searchFrom: CompositionNode = composition.root as GroupNode ): Shape { val shapes = searchFrom.findShapes() var from = shape for (subtract in shapes) { if (shape.bounds.intersects(subtract.shape.bounds)) { from = difference(from, subtract.shape) } } return from } /** * Find distances to each contour in the composition tree (or starting node) * @param point the query point * @param searchFrom a node from which the search starts, defaults to composition root * @return a sorted list of [ShapeNodeNearestContour] describing distance to every contour */ fun distances( point: Vector2, searchFrom: CompositionNode = composition.root as GroupNode ): List { return searchFrom.findShapes().flatMap { node -> node.shape.contours.filter { !it.empty } .map { it.nearest(point) } .map { ShapeNodeNearestContour(node, it, point - it.position, it.position.distanceTo(point)) } }.sortedBy { it.distance } } fun CompositionNode.distances(point: Vector2): List = distances(point, searchFrom = this) /** * Test a given `contour` against org.openrndr.shape.contours in the composition tree * @param contour the query contour * @param searchFrom a node from which the search starts, defaults to composition root * @param mergeThreshold minimum distance between intersections before they are merged together, * 0.0 or lower means no org.openrndr.shape.merge * @return a list of `org.openrndr.shape.ShapeNodeIntersection` */ fun intersections( contour: ShapeContour, searchFrom: CompositionNode = composition.root as GroupNode, mergeThreshold: Double = 0.5 ): List { return searchFrom.findShapes().pflatMap { node -> if (node.bounds.intersects(contour.bounds)) { node.shape.contours.flatMap { nodeContour -> intersections(contour, nodeContour).map { ShapeNodeIntersection(node, it) } } } else { emptyList() } }.let { if (mergeThreshold > 0.0) { it.merge(mergeThreshold) } else { it } } } fun CompositionNode.intersections(contour: ShapeContour, mergeThreshold: Double = 0.5) = intersections(contour, this, mergeThreshold) /** * Test a given `shape` against org.openrndr.shape.contours in the composition tree * @param shape the query shape * @param searchFrom a node from which the search starts, defaults to composition root * @return a list of `org.openrndr.shape.ShapeNodeIntersection` */ fun intersections( shape: Shape, searchFrom: CompositionNode = composition.root as GroupNode, mergeThreshold: Double = 0.5 ): List { return shape.contours.flatMap { intersections(it, searchFrom, mergeThreshold) } } fun CompositionNode.intersections(shape: Shape, mergeThreshold: Double = 0.5) = intersections(shape, this, mergeThreshold) fun shape(shape: Shape, insert: Boolean = true): ShapeNode? { if (shape.empty) { return null } val inverseModel = model.inversed val postShape = mask?.let { intersection(shape, it.transform(inverseModel)) } ?: shape if (postShape.empty) { return null } // only use clipping for open shapes val clipMode = if (postShape.topology == ShapeTopology.CLOSED) clipMode else ClipMode.DISABLED return when (clipMode.op) { ClipOp.DISABLED, ClipOp.REVERSE_DIFFERENCE -> { val shapeNode = ShapeNode(postShape) val shapeTransform: Matrix44 = when (transformMode) { TransformMode.KEEP -> { shapeNode.transform = model Matrix44.IDENTITY } TransformMode.APPLY -> { shapeNode.transform = Matrix44.IDENTITY model } } shapeNode.shape = when (clipMode.op) { ClipOp.DISABLED -> postShape.transform(shapeTransform) ClipOp.REVERSE_DIFFERENCE -> { val shapeNodes = (if (!clipMode.grouped) composition.findShapes() else cursor.findShapes()) var toInsert = shape val inverse = model.inversed for (node in shapeNodes) { if (toInsert.empty) { break } else { toInsert = difference(toInsert, node.effectiveShape.transform(inverse)) } } toInsert } else -> error("unreachable") } shapeNode.stroke = stroke shapeNode.strokeOpacity = strokeOpacity shapeNode.strokeWeight = strokeWeight shapeNode.miterLimit = miterlimit shapeNode.lineCap = lineCap shapeNode.lineJoin = lineJoin shapeNode.fill = fill shapeNode.fillOpacity = fillOpacity if (insert) { cursor.children.add(shapeNode) shapeNode.parent = cursor } shapeNode } else -> { val shapeNodes = (if (!clipMode.grouped) composition.findShapes() else cursor.findShapes()) val toRemove = mutableListOf() shapeNodes.pforEach { shapeNode -> val inverse = shapeNode.effectiveTransform.inversed val transformedShape = postShape.transform(inverse * model) val operated = when (clipMode.op) { ClipOp.INTERSECT -> intersection(shapeNode.shape, transformedShape) ClipOp.UNION -> union(shapeNode.shape, transformedShape) ClipOp.DIFFERENCE -> difference(shapeNode.shape, transformedShape) else -> error("unsupported base op ${clipMode.op}") } if (!operated.empty) { shapeNode.shape = operated } else { //synchronized(toRemove) { toRemove.add(shapeNode) //} } } for (node in toRemove) { node.remove() } null } } } fun shapes(shapes: List, insert: Boolean = true) = shapes.map { shape(it, insert) } fun rectangle(rectangle: Rectangle, closed: Boolean = true, insert: Boolean = true) = contour(rectangle.contour.let { if (closed) { it } else { it.open } }, insert = insert) fun rectangle(x: Double, y: Double, width: Double, height: Double, closed: Boolean = true, insert: Boolean = true) = rectangle( Rectangle(x, y, width, height), closed, insert) fun rectangles(rectangles: List, insert: Boolean = true) = rectangles.map { rectangle(it, insert) } fun rectangles(positions: List, width: Double, height: Double, insert: Boolean = true) = rectangles(positions.map { Rectangle(it, width, height) }, insert) fun rectangles(positions: List, dimensions: List, insert: Boolean) = rectangles((positions zip dimensions).map { Rectangle(it.first, it.second.x, it.second.y) }, insert) fun circle(x: Double, y: Double, radius: Double, closed: Boolean = true, insert: Boolean = true) = circle( Circle( Vector2(x, y), radius ), closed, insert) fun circle(position: Vector2, radius: Double, closed: Boolean = true, insert: Boolean = true) = circle( Circle( position, radius ), closed, insert) fun circle(circle: Circle, closed: Boolean = true, insert: Boolean = true) = contour(circle.contour.let { if (closed) { it } else { it.open } }, insert) fun circles(circles: List, insert: Boolean = true) = circles.map { circle(it, insert) } fun circles(positions: List, radius: Double, insert: Boolean = true) = circles(positions.map { Circle( it, radius ) }, insert) fun circles(positions: List, radii: List, insert: Boolean = true) = circles((positions zip radii).map { Circle( it.first, it.second ) }, insert) /* fun ellipse( x: Double, y: Double, xRadius: Double, yRadius: Double, rotationInDegrees: Double = 0.0, closed: Boolean = true, insert: Boolean = true ) = ellipse(Vector2(x, y), xRadius, yRadius, rotationInDegrees, closed, insert) fun ellipse( center: Vector2, xRadius: Double, yRadius: Double, rotationInDegrees: Double, closed: Boolean = true, insert: Boolean = true ) = contour(OrientedEllipse(center, xRadius, yRadius, rotationInDegrees).contour.let { if (closed) { it } else { it.open } }, insert) */ fun lineSegment( startX: Double, startY: Double, endX: Double, endY: Double, insert: Boolean = true ) = lineSegment(LineSegment(startX, startY, endX, endY), insert) fun lineSegment( start: Vector2, end: Vector2, insert: Boolean = true ) = lineSegment(LineSegment(start, end), insert) fun lineSegment( lineSegment: LineSegment, insert: Boolean = true ) = contour(lineSegment.contour, insert) fun lineSegments( lineSegments: List, insert: Boolean = true ) = lineSegments.map { lineSegment(it, insert) } fun segment( start: Vector2, c0: Vector2, c1: Vector2, end: Vector2, insert: Boolean = true ) = segment(Segment(start, c0, c1, end), insert) fun segment( start: Vector2, c0: Vector2, end: Vector2, insert: Boolean = true ) = segment(Segment(start, c0, end), insert) fun segment( start: Vector2, end: Vector2, insert: Boolean = true ) = segment(Segment(start, end), insert) fun segment( segment: Segment, insert: Boolean = true ) = contour(segment.contour, insert) fun segments( segments: List, insert: Boolean = true ) = segments.map { segment(it, insert) } fun lineStrip( points: List, insert: Boolean = true ) = contour(ShapeContour.fromPoints(points, false, YPolarity.CW_NEGATIVE_Y), insert) fun lineLoop( points: List, insert: Boolean = true ) = contour(ShapeContour.fromPoints(points, true, YPolarity.CW_NEGATIVE_Y), insert) fun text( text: String, position: Vector2, insert: Boolean = true ): TextNode { val g = GroupNode() g.style.transform = Transform.Matrix(transform { translate(position.xy0) }) val textNode = TextNode(text, null).apply { this.style.fill = when (val f = this@CompositionDrawer.fill) { is ColorRGBa -> Paint.RGB(f) else -> Paint.None } } g.children.add(textNode) if (insert) { cursor.children.add(g) } return textNode } fun textOnContour( text: String, contour: ShapeContour, insert: Boolean = true ): TextNode { val textNode = TextNode(text, contour) if (insert) { cursor.children.add(textNode) } return textNode } fun texts(text: List, positions: List) = (text zip positions).map { text(it.first, it.second) } /** * Adds an image to the composition tree */ fun image( image: ColorBuffer, x: Double = 0.0, y: Double = 0.0, insert: Boolean = true ): ImageNode { val node = ImageNode(image, x, y, width = image.width.toDouble(), height = image.height.toDouble()) node.style.transform = Transform.Matrix(this.model) if (insert) { cursor.children.add(node) } return node } fun composition(composition: Composition): CompositionNode { val rootContainer = GroupNode() val newRoot = composition.root.duplicate(insert = false) newRoot.parent = rootContainer rootContainer.children.add(newRoot) rootContainer.transform *= model rootContainer.parent = cursor cursor.children.add(rootContainer) return rootContainer } fun CompositionNode.translate(x: Double, y: Double, z: Double = 0.0) { transform = transform.transform { translate(x, y, z) } } fun CompositionNode.rotate(angleInDegrees: Double, pivot: Vector2 = Vector2.ZERO) { transform = transform.transform { translate(pivot.xy0) rotate(Vector3.UNIT_Z, angleInDegrees) translate(-pivot.xy0) } } fun CompositionNode.scale(scale: Double, pivot: Vector2 = Vector2.ZERO) { transform = transform.transform { translate(pivot.xy0) scale(scale, scale, scale) translate(-pivot.xy0) } } fun CompositionNode.transform(builder: TransformBuilder.() -> Unit) { return this.transform(builder) } /** * Returns a deep copy of a [CompositionNode]. * If [insert] is true the copy is inserted at [cursor]. * @return a deep copy of the node */ // TODO: Include new features fun CompositionNode.duplicate(insert: Boolean = true): CompositionNode { fun nodeCopy(node: CompositionNode): CompositionNode { val copy = when (node) { is ImageNode -> { ImageNode(node.image, node.x, node.y, node.width, node.height) } is ShapeNode -> { ShapeNode(node.shape) } is TextNode -> { TextNode(node.text, node.contour) } is GroupNode -> { val children = node.children.map { nodeCopy(it) }.toMutableList() val groupNode = GroupNode(children) groupNode.children.forEach { it.parent = groupNode } groupNode } } copy.style = node.style return copy } val copy = nodeCopy(this) if (insert) { this@CompositionDrawer.cursor.children.add(copy) copy.parent = cursor } return copy } } /** * Creates a [Composition]. The draw operations contained inside * the [drawFunction] do not render graphics to the screen, * but populate the Composition instead. */ fun drawComposition( documentBounds: CompositionDimensions = defaultCompositionDimensions, composition: Composition? = null, cursor: GroupNode? = composition?.root as? GroupNode, drawFunction: CompositionDrawer.() -> Unit ): Composition = CompositionDrawer(documentBounds, composition, cursor).apply { drawFunction() }.composition /** * Draw into an existing [Composition]. */ fun Composition.draw(drawFunction: CompositionDrawer.() -> Unit) { drawComposition(composition = this, drawFunction = drawFunction) }