diff --git a/orx-shapes/src/main/kotlin/operators/ChamferCorners.kt b/orx-shapes/src/main/kotlin/operators/ChamferCorners.kt new file mode 100644 index 00000000..973bf32e --- /dev/null +++ b/orx-shapes/src/main/kotlin/operators/ChamferCorners.kt @@ -0,0 +1,125 @@ +package org.openrndr.extra.shapes.operators + +import org.openrndr.math.Vector2 +import org.openrndr.shape.* +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sqrt + +private fun Segment.linearSub(l0: Double, l1: Double): Segment { + return sub(l0 / length, l1 / length) +} + +private fun Segment.linearPosition(l: Double): Vector2 { + return position((l / length).coerceIn(0.0, 1.0)) +} + +private fun pickLength(leftLength: Double, rightLength: Double, s0: Segment, s1: Segment): Double { + val p3 = s1.end + val p2 = s0.end + val p1 = s0.start + + val det = (p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y) + + return if (det < 0.0) { + leftLength + } else { + rightLength + } +} + +/** + * Chamfers corners between linear segments + * @param length the length of the chamfer + * @param angleThreshold the maximum (smallest) angle between between linear segments + * @param chamfer the chamfer function to apply + */ +fun ShapeContour.chamferCorners( + leftLength: Double, + rightLength: Double = leftLength, + angleThreshold: Double = 180.0, + chamfer: ContourBuilder.(p1: Vector2, p2: Vector2, p3: Vector2) -> Unit +) = contour { + val sourceSegments = if (closed) { + (this@chamferCorners.segments + this@chamferCorners.segments.first()) + } else { + this@chamferCorners.segments + } + + // Prelude + if ((this@chamferCorners).closed && sourceSegments[sourceSegments.size - 2].linear && sourceSegments.first().linear) { + val length = pickLength(leftLength, rightLength, sourceSegments.last(), sourceSegments.first()) + if (length <= sourceSegments[0].length / 2) { + moveTo(sourceSegments[0].linearPosition(length)) + } else { + moveTo(sourceSegments[0].position(0.0)) + } + } else { + moveTo(position(0.0)) + } + + for ((s0, s1) in sourceSegments.zipWithNext()) { + if (s0.control.size == 1) { + curveTo(s0.control[0], s0.end) + } else if (s0.control.size == 2) { + curveTo(s0.control[0], s0.control[1], s0.end) + } else if (s0.linear) { + val length = pickLength(leftLength, rightLength, s0, s1) + if (s0.linear && s1.linear && length <= s0.length / 2 && length <= s1.length / 2) { + val p0 = s0.linearPosition(s0.length - length) + val p1 = s1.linearPosition(length) + lineTo(p0) + chamfer(p0, s0.end, p1) + } else { + lineTo(s0.end) + } + } + } + + // Postlude + if (closed) { + close() + } else { + val last = sourceSegments.last() + when { + last.linear -> { + if (length <= last.length / 2) { + lineTo(last.linearPosition(length)) + } else { + lineTo(last.end) + } + } + last.control.size == 1 -> { + curveTo(last.control[0], last.end) + } + last.control.size == 2 -> { + curveTo(last.control[0], last.control[1], last.end) + } + } + } +} + +fun ShapeContour.bevelCorners(length: Double, angleThreshold: Double = 180.0): ShapeContour = + chamferCorners(length, length, angleThreshold) { _, _, p3 -> + lineTo(p3) + } + +fun ShapeContour.roundCorners(length: Double, angleThreshold: Double = 180.0): ShapeContour = + chamferCorners(length, length, angleThreshold) { _, p2, p3 -> + curveTo(p2, p3) + } + +fun ShapeContour.arcCorners(leftLength: Double, rightLength: Double = leftLength, + leftScale: Double = 1.0, rightScale: Double = leftScale, + leftLargeArc : Boolean = false, rightLargeArc : Boolean = leftLargeArc, + angleThreshold: Double = 180.0): ShapeContour = + chamferCorners(abs(leftLength), abs(rightLength), angleThreshold) { p1, p2, p3 -> + val dx = abs(p3.x - p2.x) + val dy = abs(p3.y - p2.y) + val radius = sqrt(dx * dx + dy * dy) + val det = (p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y) + val scale = if (det < 0.0) leftScale else rightScale + val sweep = scale * sign(det) + val largeArc = if (det < 0.0) leftLargeArc else rightLargeArc + arcTo(radius * abs(scale) , radius * abs(scale), 90.0, largeArc, sweep > 0.0, p3) + } \ No newline at end of file diff --git a/orx-shapes/src/test/kotlin/Assertions.kt b/orx-shapes/src/test/kotlin/Assertions.kt new file mode 100644 index 00000000..aa246dd1 --- /dev/null +++ b/orx-shapes/src/test/kotlin/Assertions.kt @@ -0,0 +1,7 @@ +import org.amshove.kluent.shouldBeInRange +import org.openrndr.math.Vector2 + +infix fun Vector2.`should be near`(other: Vector2) { + x shouldBeInRange (other.x - 0.00001..other.x + 0.00001) + y shouldBeInRange (other.y - 0.00001..other.y + 0.00001) +} diff --git a/orx-shapes/src/test/kotlin/TestChamferCorners.kt b/orx-shapes/src/test/kotlin/TestChamferCorners.kt new file mode 100644 index 00000000..eecf3a98 --- /dev/null +++ b/orx-shapes/src/test/kotlin/TestChamferCorners.kt @@ -0,0 +1,153 @@ +import org.amshove.kluent.`should be equal to` +import org.openrndr.extra.shapes.operators.bevelCorners +import org.openrndr.extra.shapes.operators.roundCorners +import org.openrndr.extra.shapes.regularPolygon +import org.openrndr.shape.Circle +import org.openrndr.shape.contour +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object TestChamferCorners : Spek({ + + describe("a single segment linear contour") { + val c = contour { + moveTo(0.0, 0.0) + lineTo(100.0, 100.0) + } + + it("should be similar to a chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` 1 + cc.position(0.0) `should be near` c.position(0.0) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a single segment quadratic contour") { + val c = contour { + moveTo(0.0, 0.0) + curveTo(40.0, 40.0, 100.0, 100.0) + } + + it("should be similar to a chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` 1 + cc.position(0.0) `should be near` c.position(0.0) + cc.position(0.5) `should be near` c.position(0.5) + cc.position(1.0) `should be near` c.position(1.0) + } + } + + describe("a circle contour") { + val c = Circle(0.0, 0.0, 200.0).contour + + it("should be similar to a chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` c.segments.size + cc.position(0.0) `should be near` c.position(0.0) + cc.position(0.5) `should be near` c.position(0.5) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a two segment linear contour") { + val c = contour { + moveTo(0.0, 0.0) + lineTo(50.0, 50.0) + lineTo(100.0, 50.0) + } + it("should chamfer correctly") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` 3 + cc.position(0.0) `should be near` c.position(0.0) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a two segment linear-curve contour") { + val c = contour { + moveTo(0.0, 0.0) + lineTo(50.0, 50.0) + curveTo(80.0, 120.0, 100.0, 50.0) + } + it("should be identical to the chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` c.segments.size + cc.position(0.0) `should be near` c.position(0.0) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a two segment curve-linear contour") { + val c = contour { + moveTo(0.0, 0.0) + curveTo(80.0, 120.0, 50.0, 50.0) + lineTo(100.0, 50.0) + + } + it("should be identical to the chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` c.segments.size + cc.position(0.0) `should be near` c.position(0.0) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a two segment curve-linear contour") { + val c = contour { + moveTo(0.0, 0.0) + curveTo(80.0, 120.0, 50.0, 50.0) + lineTo(100.0, 50.0) + + } + it("should be identical to the chamfered version") { + val cc = c.bevelCorners(10.0) + cc.segments.size `should be equal to` c.segments.size + cc.position(0.0) `should be near` c.position(0.0) + cc.position(1.0) `should be near` c.position(1.0) + cc.closed `should be equal to` c.closed + } + } + + describe("a triangle") { + val c = regularPolygon(3, radius = 100.0) + + c.closed `should be equal to` true + + val cc = c.roundCorners(1.0) + + c.closed `should be equal to` cc.closed + + val ccc = cc.roundCorners(1.0) + + ccc.closed `should be equal to` cc.closed + + cc.segments.size `should be equal to` 6 + + cc.segments.forEach { + println(it) + } + + println("---") + ccc.segments.forEach { + println(it) + } + it("should have 6 sides") { + ccc.segments.size `should be equal to` cc.segments.size + } + it("should start at the right position") { + ccc.position(0.0) `should be near` cc.position(0.0) + } + it("should end at the right position") { + ccc.position(1.0) `should be near` cc.position(1.0) + } + + + + } +}) \ No newline at end of file