From 38783e613243910d9e1b9ed01e7a2cd5154a5018 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 06:53:32 -0700 Subject: [PATCH 1/8] Check for ray striking and missing a cylinder (Implemented using Anthropic's Claude) --- src/main/scala/Cylinder.scala | 111 +++++++++++++++++++++++++++++ src/test/scala/CylinderSuite.scala | 67 +++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/main/scala/Cylinder.scala create mode 100644 src/test/scala/CylinderSuite.scala diff --git a/src/main/scala/Cylinder.scala b/src/main/scala/Cylinder.scala new file mode 100644 index 0000000..8af67aa --- /dev/null +++ b/src/main/scala/Cylinder.scala @@ -0,0 +1,111 @@ +package com.samuelcantrell.raytracer.cylinder + +import com.samuelcantrell.raytracer.ray +import com.samuelcantrell.raytracer.tuple +import com.samuelcantrell.raytracer.intersection +import com.samuelcantrell.raytracer.matrix +import com.samuelcantrell.raytracer.material +import com.samuelcantrell.raytracer.shape +import java.util.UUID + +case class Cylinder( + id: String = UUID.randomUUID().toString, + transform: matrix.Matrix = matrix.Matrix.identity(), + objectMaterial: material.Material = material.material(), + minimum: Double = Double.NegativeInfinity, + maximum: Double = Double.PositiveInfinity, + closed: Boolean = false +) extends shape.Shape { + + def localIntersect(localRay: ray.Ray): intersection.Intersections = { + val a = localRay.direction.x * localRay.direction.x + + localRay.direction.z * localRay.direction.z + + // Ray is parallel to the y axis + if (math.abs(a) < 1e-10) { + intersection.Intersections(Array.empty[intersection.Intersection]) + } else { + val b = 2 * localRay.origin.x * localRay.direction.x + + 2 * localRay.origin.z * localRay.direction.z + val c = localRay.origin.x * localRay.origin.x + + localRay.origin.z * localRay.origin.z - 1 + + val discriminant = b * b - 4 * a * c + + // Ray does not intersect the cylinder + if (discriminant < 0) { + intersection.Intersections(Array.empty[intersection.Intersection]) + } else { + val t0 = (-b - math.sqrt(discriminant)) / (2 * a) + val t1 = (-b + math.sqrt(discriminant)) / (2 * a) + + val (t0_sorted, t1_sorted) = if (t0 > t1) (t1, t0) else (t0, t1) + + val y0 = localRay.origin.y + t0_sorted * localRay.direction.y + val y1 = localRay.origin.y + t1_sorted * localRay.direction.y + + val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() + + if (minimum < y0 && y0 < maximum) { + intersections += intersection.intersection(t0_sorted, this) + } + + if (minimum < y1 && y1 < maximum) { + intersections += intersection.intersection(t1_sorted, this) + } + + intersection.Intersections(intersections.toArray) + } + } + } + + def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + + if (dist < 1 && localPoint.y >= maximum - 1e-10) { + tuple.makeVector(0, 1, 0) + } else if (dist < 1 && localPoint.y <= minimum + 1e-10) { + tuple.makeVector(0, -1, 0) + } else { + tuple.makeVector(localPoint.x, 0, localPoint.z) + } + } + + def withTransform(newTransform: matrix.Matrix): Cylinder = { + this.copy(transform = newTransform) + } + + def withMaterial(newMaterial: material.Material): Cylinder = { + this.copy(objectMaterial = newMaterial) + } +} + +def cylinder(): Cylinder = { + Cylinder() +} + +// Convenience functions for backward compatibility +def setTransform(c: Cylinder, t: matrix.Matrix): Cylinder = { + shape.setTransform(c, t) +} + +def setMaterial(c: Cylinder, m: material.Material): Cylinder = { + shape.setMaterial(c, m) +} + +def intersect(c: Cylinder, r: ray.Ray): intersection.Intersections = { + shape.intersect(c, r) +} + +def normalAt(c: Cylinder, worldPoint: tuple.Tuple): tuple.Tuple = { + shape.normalAt(c, worldPoint) +} + +// Direct access to local methods for testing +def localIntersect(c: Cylinder, localRay: ray.Ray): intersection.Intersections = { + c.localIntersect(localRay) +} + +def localNormalAt(c: Cylinder, localPoint: tuple.Tuple): tuple.Tuple = { + c.localNormalAt(localPoint) +} \ No newline at end of file diff --git a/src/test/scala/CylinderSuite.scala b/src/test/scala/CylinderSuite.scala new file mode 100644 index 0000000..a0dd1d7 --- /dev/null +++ b/src/test/scala/CylinderSuite.scala @@ -0,0 +1,67 @@ +import com.samuelcantrell.raytracer.cylinder._ +import com.samuelcantrell.raytracer.ray +import com.samuelcantrell.raytracer.tuple +import com.samuelcantrell.raytracer.equality + +class CylinderSuite extends munit.FunSuite { + + test("A ray misses a cylinder - test 1") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) + val r = ray.ray(tuple.makePoint(1, 0, 0), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("A ray misses a cylinder - test 2") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) + val r = ray.ray(tuple.makePoint(0, 0, 0), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("A ray misses a cylinder - test 3") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(1, 1, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("A ray strikes a cylinder - test 1") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(1, 0, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 5.0), true) + assertEquals(equality.almostEqual(xs(1).t, 5.0), true) + } + + test("A ray strikes a cylinder - test 2") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 4.0), true) + assertEquals(equality.almostEqual(xs(1).t, 6.0), true) + } + + test("A ray strikes a cylinder - test 3") { + val cyl = cylinder() + val direction = tuple.normalize(tuple.makeVector(0.1, 1, 1)) + val r = ray.ray(tuple.makePoint(0.5, 0, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 6.80798, 0.0001), true) + assertEquals(equality.almostEqual(xs(1).t, 7.08872, 0.0001), true) + } +} From 0adde86573ac0328624c7df2a58909f654aabdb3 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 06:55:07 -0700 Subject: [PATCH 2/8] Compute normal vector for a cylinder (Implemented using Anthropic's Claude) --- src/test/scala/CylinderSuite.scala | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/scala/CylinderSuite.scala b/src/test/scala/CylinderSuite.scala index a0dd1d7..e02ebd0 100644 --- a/src/test/scala/CylinderSuite.scala +++ b/src/test/scala/CylinderSuite.scala @@ -64,4 +64,36 @@ class CylinderSuite extends munit.FunSuite { assertEquals(equality.almostEqual(xs(0).t, 6.80798, 0.0001), true) assertEquals(equality.almostEqual(xs(1).t, 7.08872, 0.0001), true) } + + test("Normal vector on a cylinder - point(1, 0, 0)") { + val cyl = cylinder() + val n = localNormalAt(cyl, tuple.makePoint(1, 0, 0)) + val expected = tuple.makeVector(1, 0, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("Normal vector on a cylinder - point(0, 5, -1)") { + val cyl = cylinder() + val n = localNormalAt(cyl, tuple.makePoint(0, 5, -1)) + val expected = tuple.makeVector(0, 0, -1) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("Normal vector on a cylinder - point(0, -2, 1)") { + val cyl = cylinder() + val n = localNormalAt(cyl, tuple.makePoint(0, -2, 1)) + val expected = tuple.makeVector(0, 0, 1) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("Normal vector on a cylinder - point(-1, 1, 0)") { + val cyl = cylinder() + val n = localNormalAt(cyl, tuple.makePoint(-1, 1, 0)) + val expected = tuple.makeVector(-1, 0, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } } From 6cf5dbc4dc8cd7c81a4debb040935e5ecc86784b Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:35:16 -0700 Subject: [PATCH 3/8] Add tests for cylinder length (Implemented using Anthropic's Claude) --- src/test/scala/CylinderSuite.scala | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/test/scala/CylinderSuite.scala b/src/test/scala/CylinderSuite.scala index e02ebd0..fe9dd08 100644 --- a/src/test/scala/CylinderSuite.scala +++ b/src/test/scala/CylinderSuite.scala @@ -5,6 +5,13 @@ import com.samuelcantrell.raytracer.equality class CylinderSuite extends munit.FunSuite { + test("The default minimum and maximum for a cylinder") { + val cyl = cylinder() + + assertEquals(cyl.minimum, Double.NegativeInfinity) + assertEquals(cyl.maximum, Double.PositiveInfinity) + } + test("A ray misses a cylinder - test 1") { val cyl = cylinder() val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) @@ -96,4 +103,58 @@ class CylinderSuite extends munit.FunSuite { assertEquals(tuple.isEqual(n, expected), true) } + + test("Intersecting a constrained cylinder - test 1") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0.1, 1, 0)) + val r = ray.ray(tuple.makePoint(0, 1.5, 0), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a constrained cylinder - test 2") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 3, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a constrained cylinder - test 3") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a constrained cylinder - test 4") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 2, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a constrained cylinder - test 5") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 1, -5), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a constrained cylinder - test 6") { + val cyl = cylinder().copy(minimum = 1, maximum = 2) + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 1.5, -2), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } } From 655e03af65106894ade21c275e2cfdc884868ea9 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:41:50 -0700 Subject: [PATCH 4/8] Use EPSILON for checking (Implemented using Anthropic's Claude) --- src/main/scala/Cylinder.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/scala/Cylinder.scala b/src/main/scala/Cylinder.scala index 8af67aa..4b6fcf9 100644 --- a/src/main/scala/Cylinder.scala +++ b/src/main/scala/Cylinder.scala @@ -6,6 +6,7 @@ import com.samuelcantrell.raytracer.intersection import com.samuelcantrell.raytracer.matrix import com.samuelcantrell.raytracer.material import com.samuelcantrell.raytracer.shape +import com.samuelcantrell.raytracer.equality import java.util.UUID case class Cylinder( @@ -62,9 +63,9 @@ case class Cylinder( def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z - if (dist < 1 && localPoint.y >= maximum - 1e-10) { + if (dist < 1 && localPoint.y >= maximum - equality.EPSILON) { tuple.makeVector(0, 1, 0) - } else if (dist < 1 && localPoint.y <= minimum + 1e-10) { + } else if (dist < 1 && localPoint.y <= minimum + equality.EPSILON) { tuple.makeVector(0, -1, 0) } else { tuple.makeVector(localPoint.x, 0, localPoint.z) From 321107162f26e1173e47d6806930038db0f7cf45 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:42:15 -0700 Subject: [PATCH 5/8] Add caps to cylinders (Implemented using Anthropic's Claude) --- src/main/scala/Cylinder.scala | 51 +++++++++++---- src/test/scala/CylinderSuite.scala | 99 ++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/main/scala/Cylinder.scala b/src/main/scala/Cylinder.scala index 4b6fcf9..eab78a7 100644 --- a/src/main/scala/Cylinder.scala +++ b/src/main/scala/Cylinder.scala @@ -19,13 +19,14 @@ case class Cylinder( ) extends shape.Shape { def localIntersect(localRay: ray.Ray): intersection.Intersections = { + val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() + + // Check for intersections with the cylinder walls val a = localRay.direction.x * localRay.direction.x + localRay.direction.z * localRay.direction.z - // Ray is parallel to the y axis - if (math.abs(a) < 1e-10) { - intersection.Intersections(Array.empty[intersection.Intersection]) - } else { + // Ray is not parallel to the y axis + if (math.abs(a) >= equality.EPSILON) { val b = 2 * localRay.origin.x * localRay.direction.x + 2 * localRay.origin.z * localRay.direction.z val c = localRay.origin.x * localRay.origin.x + @@ -33,10 +34,8 @@ case class Cylinder( val discriminant = b * b - 4 * a * c - // Ray does not intersect the cylinder - if (discriminant < 0) { - intersection.Intersections(Array.empty[intersection.Intersection]) - } else { + // Ray intersects the cylinder + if (discriminant >= 0) { val t0 = (-b - math.sqrt(discriminant)) / (2 * a) val t1 = (-b + math.sqrt(discriminant)) / (2 * a) @@ -45,8 +44,6 @@ case class Cylinder( val y0 = localRay.origin.y + t0_sorted * localRay.direction.y val y1 = localRay.origin.y + t1_sorted * localRay.direction.y - val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() - if (minimum < y0 && y0 < maximum) { intersections += intersection.intersection(t0_sorted, this) } @@ -54,10 +51,40 @@ case class Cylinder( if (minimum < y1 && y1 < maximum) { intersections += intersection.intersection(t1_sorted, this) } - - intersection.Intersections(intersections.toArray) } } + + // Check for intersections with the caps if the cylinder is closed + if (closed) { + intersectCaps(localRay, intersections) + } + + intersection.Intersections(intersections.toArray) + } + + private def intersectCaps(localRay: ray.Ray, intersections: scala.collection.mutable.ArrayBuffer[intersection.Intersection]): Unit = { + // Check if ray is parallel to the xz plane + if (math.abs(localRay.direction.y) < equality.EPSILON) { + return + } + + // Check intersection with lower cap at y = minimum + val t_lower = (minimum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_lower)) { + intersections += intersection.intersection(t_lower, this) + } + + // Check intersection with upper cap at y = maximum + val t_upper = (maximum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_upper)) { + intersections += intersection.intersection(t_upper, this) + } + } + + private def checkCap(r: ray.Ray, t: Double): Boolean = { + val x = r.origin.x + t * r.direction.x + val z = r.origin.z + t * r.direction.z + (x * x + z * z) <= 1 } def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { diff --git a/src/test/scala/CylinderSuite.scala b/src/test/scala/CylinderSuite.scala index fe9dd08..d10f157 100644 --- a/src/test/scala/CylinderSuite.scala +++ b/src/test/scala/CylinderSuite.scala @@ -12,6 +12,12 @@ class CylinderSuite extends munit.FunSuite { assertEquals(cyl.maximum, Double.PositiveInfinity) } + test("The default closed value for a cylinder") { + val cyl = cylinder() + + assertEquals(cyl.closed, false) + } + test("A ray misses a cylinder - test 1") { val cyl = cylinder() val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) @@ -157,4 +163,97 @@ class CylinderSuite extends munit.FunSuite { assertEquals(xs.count, 2) } + + test("Intersecting the caps of a closed cylinder - test 1") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, -1, 0)) + val r = ray.ray(tuple.makePoint(0, 3, 0), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } + + test("Intersecting the caps of a closed cylinder - test 2") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, -1, 2)) + val r = ray.ray(tuple.makePoint(0, 3, -2), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } + + test("Intersecting the caps of a closed cylinder - test 3") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, -1, 1)) + val r = ray.ray(tuple.makePoint(0, 4, -2), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } + + test("Intersecting the caps of a closed cylinder - test 4") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, 1, 2)) + val r = ray.ray(tuple.makePoint(0, 0, -2), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } + + test("Intersecting the caps of a closed cylinder - test 5") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, 1, 1)) + val r = ray.ray(tuple.makePoint(0, -1, -2), direction) + val xs = localIntersect(cyl, r) + + assertEquals(xs.count, 2) + } + + test("The normal vector on a cylinder's end caps - point(0, 1, 0)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0, 1, 0)) + val expected = tuple.makeVector(0, -1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("The normal vector on a cylinder's end caps - point(0.5, 1, 0)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0.5, 1, 0)) + val expected = tuple.makeVector(0, -1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("The normal vector on a cylinder's end caps - point(0, 1, 0.5)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0, 1, 0.5)) + val expected = tuple.makeVector(0, -1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("The normal vector on a cylinder's end caps - point(0, 2, 0)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0, 2, 0)) + val expected = tuple.makeVector(0, 1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("The normal vector on a cylinder's end caps - point(0.5, 2, 0)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0.5, 2, 0)) + val expected = tuple.makeVector(0, 1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("The normal vector on a cylinder's end caps - point(0, 2, 0.5)") { + val cyl = cylinder().copy(minimum = 1, maximum = 2, closed = true) + val n = localNormalAt(cyl, tuple.makePoint(0, 2, 0.5)) + val expected = tuple.makeVector(0, 1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } } From d7423106c43f927f1b1f1796de5a737f3fed543e Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:49:51 -0700 Subject: [PATCH 6/8] Add Cone primitive (Implemented using Anthropic's Claude) --- src/main/scala/Cone.scala | 154 +++++++++++++++++++++++++++++++++ src/test/scala/ConeSuite.scala | 101 +++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/main/scala/Cone.scala create mode 100644 src/test/scala/ConeSuite.scala diff --git a/src/main/scala/Cone.scala b/src/main/scala/Cone.scala new file mode 100644 index 0000000..e4a1966 --- /dev/null +++ b/src/main/scala/Cone.scala @@ -0,0 +1,154 @@ +package com.samuelcantrell.raytracer.cone + +import com.samuelcantrell.raytracer.ray +import com.samuelcantrell.raytracer.tuple +import com.samuelcantrell.raytracer.intersection +import com.samuelcantrell.raytracer.matrix +import com.samuelcantrell.raytracer.material +import com.samuelcantrell.raytracer.shape +import com.samuelcantrell.raytracer.equality +import java.util.UUID + +case class Cone( + id: String = UUID.randomUUID().toString, + transform: matrix.Matrix = matrix.Matrix.identity(), + objectMaterial: material.Material = material.material(), + minimum: Double = Double.NegativeInfinity, + maximum: Double = Double.PositiveInfinity, + closed: Boolean = false +) extends shape.Shape { + + def localIntersect(localRay: ray.Ray): intersection.Intersections = { + val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() + + // Check for intersections with the cone surface + // Cone equation: x² + z² = y² + // Substituting ray: (ox + t*dx)² + (oz + t*dz)² = (oy + t*dy)² + val a = localRay.direction.x * localRay.direction.x + + localRay.direction.z * localRay.direction.z - + localRay.direction.y * localRay.direction.y + + val b = 2 * localRay.origin.x * localRay.direction.x + + 2 * localRay.origin.z * localRay.direction.z - + 2 * localRay.origin.y * localRay.direction.y + + val c = localRay.origin.x * localRay.origin.x + + localRay.origin.z * localRay.origin.z - + localRay.origin.y * localRay.origin.y + + if (math.abs(a) < equality.EPSILON) { + // Ray is parallel to one of the cone's halves + if (math.abs(b) >= equality.EPSILON) { + val t = -c / (2 * b) + val y = localRay.origin.y + t * localRay.direction.y + if (minimum < y && y < maximum) { + intersections += intersection.intersection(t, this) + } + } + } else { + val discriminant = b * b - 4 * a * c + + if (discriminant >= 0) { + val t0 = (-b - math.sqrt(discriminant)) / (2 * a) + val t1 = (-b + math.sqrt(discriminant)) / (2 * a) + + val (t0_sorted, t1_sorted) = if (t0 > t1) (t1, t0) else (t0, t1) + + val y0 = localRay.origin.y + t0_sorted * localRay.direction.y + val y1 = localRay.origin.y + t1_sorted * localRay.direction.y + + if (minimum < y0 && y0 < maximum) { + intersections += intersection.intersection(t0_sorted, this) + } + + if (minimum < y1 && y1 < maximum) { + intersections += intersection.intersection(t1_sorted, this) + } + } + } + + // Check for intersections with the caps if the cone is closed + if (closed) { + intersectCaps(localRay, intersections) + } + + intersection.Intersections(intersections.toArray) + } + + private def intersectCaps(localRay: ray.Ray, intersections: scala.collection.mutable.ArrayBuffer[intersection.Intersection]): Unit = { + // Check if ray is parallel to the xz plane + if (math.abs(localRay.direction.y) < equality.EPSILON) { + return + } + + // Check intersection with lower cap at y = minimum + val t_lower = (minimum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_lower, minimum)) { + intersections += intersection.intersection(t_lower, this) + } + + // Check intersection with upper cap at y = maximum + val t_upper = (maximum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_upper, maximum)) { + intersections += intersection.intersection(t_upper, this) + } + } + + private def checkCap(r: ray.Ray, t: Double, y: Double): Boolean = { + val x = r.origin.x + t * r.direction.x + val z = r.origin.z + t * r.direction.z + val radius = math.abs(y) + (x * x + z * z) <= radius * radius + } + + def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + + if (dist < math.abs(localPoint.y) && localPoint.y >= maximum - equality.EPSILON) { + tuple.makeVector(0, 1, 0) + } else if (dist < math.abs(localPoint.y) && localPoint.y <= minimum + equality.EPSILON) { + tuple.makeVector(0, -1, 0) + } else { + val y = if (localPoint.y > 0) -math.sqrt(dist) else math.sqrt(dist) + tuple.makeVector(localPoint.x, y, localPoint.z) + } + } + + def withTransform(newTransform: matrix.Matrix): Cone = { + this.copy(transform = newTransform) + } + + def withMaterial(newMaterial: material.Material): Cone = { + this.copy(objectMaterial = newMaterial) + } +} + +def cone(): Cone = { + Cone() +} + +// Convenience functions for backward compatibility +def setTransform(c: Cone, t: matrix.Matrix): Cone = { + shape.setTransform(c, t) +} + +def setMaterial(c: Cone, m: material.Material): Cone = { + shape.setMaterial(c, m) +} + +def intersect(c: Cone, r: ray.Ray): intersection.Intersections = { + shape.intersect(c, r) +} + +def normalAt(c: Cone, worldPoint: tuple.Tuple): tuple.Tuple = { + shape.normalAt(c, worldPoint) +} + +// Direct access to local methods for testing +def localIntersect(c: Cone, localRay: ray.Ray): intersection.Intersections = { + c.localIntersect(localRay) +} + +def localNormalAt(c: Cone, localPoint: tuple.Tuple): tuple.Tuple = { + c.localNormalAt(localPoint) +} \ No newline at end of file diff --git a/src/test/scala/ConeSuite.scala b/src/test/scala/ConeSuite.scala new file mode 100644 index 0000000..90f3011 --- /dev/null +++ b/src/test/scala/ConeSuite.scala @@ -0,0 +1,101 @@ +import com.samuelcantrell.raytracer.cone._ +import com.samuelcantrell.raytracer.ray +import com.samuelcantrell.raytracer.tuple +import com.samuelcantrell.raytracer.equality + +class ConeSuite extends munit.FunSuite { + + test("Intersecting a cone with a ray - test 1") { + val shape = cone() + val direction = tuple.normalize(tuple.makeVector(0, 0, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 5.0), true) + assertEquals(equality.almostEqual(xs(1).t, 5.0), true) + } + + test("Intersecting a cone with a ray - test 2") { + val shape = cone() + val direction = tuple.normalize(tuple.makeVector(1, 1, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 8.66025, 0.001), true) + assertEquals(equality.almostEqual(xs(1).t, 8.66025, 0.001), true) + } + + test("Intersecting a cone with a ray - test 3") { + val shape = cone() + val direction = tuple.normalize(tuple.makeVector(-0.5, -1, 1)) + val r = ray.ray(tuple.makePoint(1, 1, -5), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 2) + assertEquals(equality.almostEqual(xs(0).t, 4.55006, 0.001), true) + assertEquals(equality.almostEqual(xs(1).t, 49.44994, 0.001), true) + } + + test("Intersecting a cone with a ray parallel to one of its halves") { + val shape = cone() + val direction = tuple.normalize(tuple.makeVector(0, 1, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -1), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 1) + assertEquals(equality.almostEqual(xs(0).t, 0.35355, 0.001), true) + } + + test("Intersecting a cone's end caps - test 1") { + val shape = cone().copy(minimum = -0.5, maximum = 0.5, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) + val r = ray.ray(tuple.makePoint(0, 0, -5), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 0) + } + + test("Intersecting a cone's end caps - test 2") { + val shape = cone().copy(minimum = -0.5, maximum = 0.5, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, 1, 1)) + val r = ray.ray(tuple.makePoint(0, 0, -0.25), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 2) + } + + test("Intersecting a cone's end caps - test 3") { + val shape = cone().copy(minimum = -0.5, maximum = 0.5, closed = true) + val direction = tuple.normalize(tuple.makeVector(0, 1, 0)) + val r = ray.ray(tuple.makePoint(0, 0, -0.25), direction) + val xs = localIntersect(shape, r) + + assertEquals(xs.count, 4) + } + + test("Computing the normal vector on a cone - point(0, 0, 0)") { + val shape = cone() + val n = localNormalAt(shape, tuple.makePoint(0, 0, 0)) + val expected = tuple.makeVector(0, 0, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("Computing the normal vector on a cone - point(1, 1, 1)") { + val shape = cone() + val n = localNormalAt(shape, tuple.makePoint(1, 1, 1)) + val expected = tuple.makeVector(1, -math.sqrt(2), 1) + + assertEquals(tuple.isEqual(n, expected), true) + } + + test("Computing the normal vector on a cone - point(-1, -1, 0)") { + val shape = cone() + val n = localNormalAt(shape, tuple.makePoint(-1, -1, 0)) + val expected = tuple.makeVector(-1, 1, 0) + + assertEquals(tuple.isEqual(n, expected), true) + } +} \ No newline at end of file From fa308f76cb66b884c84f97de6ba15861afeabc70 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:45:08 -0700 Subject: [PATCH 7/8] Refactor Cone and Cylinder for common code (Implemented using Anthropic's Claude) --- src/main/scala/Cone.scala | 108 +++++++--------------------- src/main/scala/Cylinder.scala | 103 +++++++-------------------- src/main/scala/QuadricShape.scala | 112 ++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 161 deletions(-) create mode 100644 src/main/scala/QuadricShape.scala diff --git a/src/main/scala/Cone.scala b/src/main/scala/Cone.scala index e4a1966..234d61c 100644 --- a/src/main/scala/Cone.scala +++ b/src/main/scala/Cone.scala @@ -7,111 +7,53 @@ import com.samuelcantrell.raytracer.matrix import com.samuelcantrell.raytracer.material import com.samuelcantrell.raytracer.shape import com.samuelcantrell.raytracer.equality +import com.samuelcantrell.raytracer.quadric.QuadricShape import java.util.UUID case class Cone( - id: String = UUID.randomUUID().toString, - transform: matrix.Matrix = matrix.Matrix.identity(), - objectMaterial: material.Material = material.material(), - minimum: Double = Double.NegativeInfinity, - maximum: Double = Double.PositiveInfinity, - closed: Boolean = false -) extends shape.Shape { - - def localIntersect(localRay: ray.Ray): intersection.Intersections = { - val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() - - // Check for intersections with the cone surface + override val id: String = UUID.randomUUID().toString, + override val transform: matrix.Matrix = matrix.Matrix.identity(), + override val objectMaterial: material.Material = material.material(), + override val minimum: Double = Double.NegativeInfinity, + override val maximum: Double = Double.PositiveInfinity, + override val closed: Boolean = false +) extends QuadricShape(id, transform, objectMaterial, minimum, maximum, closed) { + + // Cone-specific implementations + protected def surfaceIntersectionCoefficients(localRay: ray.Ray): (Double, Double, Double) = { // Cone equation: x² + z² = y² - // Substituting ray: (ox + t*dx)² + (oz + t*dz)² = (oy + t*dy)² val a = localRay.direction.x * localRay.direction.x + localRay.direction.z * localRay.direction.z - localRay.direction.y * localRay.direction.y - val b = 2 * localRay.origin.x * localRay.direction.x + 2 * localRay.origin.z * localRay.direction.z - 2 * localRay.origin.y * localRay.direction.y - val c = localRay.origin.x * localRay.origin.x + localRay.origin.z * localRay.origin.z - localRay.origin.y * localRay.origin.y + (a, b, c) + } - if (math.abs(a) < equality.EPSILON) { - // Ray is parallel to one of the cone's halves - if (math.abs(b) >= equality.EPSILON) { - val t = -c / (2 * b) - val y = localRay.origin.y + t * localRay.direction.y - if (minimum < y && y < maximum) { - intersections += intersection.intersection(t, this) - } - } + protected def handleParallelRay(localRay: ray.Ray, b: Double, c: Double): Option[Double] = { + // Ray is parallel to one of the cone's halves + if (math.abs(b) >= equality.EPSILON) { + Some(-c / (2 * b)) } else { - val discriminant = b * b - 4 * a * c - - if (discriminant >= 0) { - val t0 = (-b - math.sqrt(discriminant)) / (2 * a) - val t1 = (-b + math.sqrt(discriminant)) / (2 * a) - - val (t0_sorted, t1_sorted) = if (t0 > t1) (t1, t0) else (t0, t1) - - val y0 = localRay.origin.y + t0_sorted * localRay.direction.y - val y1 = localRay.origin.y + t1_sorted * localRay.direction.y - - if (minimum < y0 && y0 < maximum) { - intersections += intersection.intersection(t0_sorted, this) - } - - if (minimum < y1 && y1 < maximum) { - intersections += intersection.intersection(t1_sorted, this) - } - } - } - - // Check for intersections with the caps if the cone is closed - if (closed) { - intersectCaps(localRay, intersections) + None } - - intersection.Intersections(intersections.toArray) } - private def intersectCaps(localRay: ray.Ray, intersections: scala.collection.mutable.ArrayBuffer[intersection.Intersection]): Unit = { - // Check if ray is parallel to the xz plane - if (math.abs(localRay.direction.y) < equality.EPSILON) { - return - } - - // Check intersection with lower cap at y = minimum - val t_lower = (minimum - localRay.origin.y) / localRay.direction.y - if (checkCap(localRay, t_lower, minimum)) { - intersections += intersection.intersection(t_lower, this) - } - - // Check intersection with upper cap at y = maximum - val t_upper = (maximum - localRay.origin.y) / localRay.direction.y - if (checkCap(localRay, t_upper, maximum)) { - intersections += intersection.intersection(t_upper, this) - } - } + protected def capRadius(y: Double): Double = math.abs(y) - private def checkCap(r: ray.Ray, t: Double, y: Double): Boolean = { - val x = r.origin.x + t * r.direction.x - val z = r.origin.z + t * r.direction.z - val radius = math.abs(y) - (x * x + z * z) <= radius * radius + protected def surfaceNormal(localPoint: tuple.Tuple): tuple.Tuple = { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + val y = if (localPoint.y > 0) -math.sqrt(dist) else math.sqrt(dist) + tuple.makeVector(localPoint.x, y, localPoint.z) } - def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { + protected def isOnCap(localPoint: tuple.Tuple, y: Double): Boolean = { val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z - - if (dist < math.abs(localPoint.y) && localPoint.y >= maximum - equality.EPSILON) { - tuple.makeVector(0, 1, 0) - } else if (dist < math.abs(localPoint.y) && localPoint.y <= minimum + equality.EPSILON) { - tuple.makeVector(0, -1, 0) - } else { - val y = if (localPoint.y > 0) -math.sqrt(dist) else math.sqrt(dist) - tuple.makeVector(localPoint.x, y, localPoint.z) - } + dist < math.abs(y) && math.abs(localPoint.y - y) < equality.EPSILON } def withTransform(newTransform: matrix.Matrix): Cone = { diff --git a/src/main/scala/Cylinder.scala b/src/main/scala/Cylinder.scala index eab78a7..dcfa5bd 100644 --- a/src/main/scala/Cylinder.scala +++ b/src/main/scala/Cylinder.scala @@ -7,96 +7,43 @@ import com.samuelcantrell.raytracer.matrix import com.samuelcantrell.raytracer.material import com.samuelcantrell.raytracer.shape import com.samuelcantrell.raytracer.equality +import com.samuelcantrell.raytracer.quadric.QuadricShape import java.util.UUID case class Cylinder( - id: String = UUID.randomUUID().toString, - transform: matrix.Matrix = matrix.Matrix.identity(), - objectMaterial: material.Material = material.material(), - minimum: Double = Double.NegativeInfinity, - maximum: Double = Double.PositiveInfinity, - closed: Boolean = false -) extends shape.Shape { - - def localIntersect(localRay: ray.Ray): intersection.Intersections = { - val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() - - // Check for intersections with the cylinder walls + override val id: String = UUID.randomUUID().toString, + override val transform: matrix.Matrix = matrix.Matrix.identity(), + override val objectMaterial: material.Material = material.material(), + override val minimum: Double = Double.NegativeInfinity, + override val maximum: Double = Double.PositiveInfinity, + override val closed: Boolean = false +) extends QuadricShape(id, transform, objectMaterial, minimum, maximum, closed) { + + // Cylinder-specific implementations + protected def surfaceIntersectionCoefficients(localRay: ray.Ray): (Double, Double, Double) = { val a = localRay.direction.x * localRay.direction.x + localRay.direction.z * localRay.direction.z - - // Ray is not parallel to the y axis - if (math.abs(a) >= equality.EPSILON) { - val b = 2 * localRay.origin.x * localRay.direction.x + - 2 * localRay.origin.z * localRay.direction.z - val c = localRay.origin.x * localRay.origin.x + - localRay.origin.z * localRay.origin.z - 1 - - val discriminant = b * b - 4 * a * c - - // Ray intersects the cylinder - if (discriminant >= 0) { - val t0 = (-b - math.sqrt(discriminant)) / (2 * a) - val t1 = (-b + math.sqrt(discriminant)) / (2 * a) - - val (t0_sorted, t1_sorted) = if (t0 > t1) (t1, t0) else (t0, t1) - - val y0 = localRay.origin.y + t0_sorted * localRay.direction.y - val y1 = localRay.origin.y + t1_sorted * localRay.direction.y - - if (minimum < y0 && y0 < maximum) { - intersections += intersection.intersection(t0_sorted, this) - } - - if (minimum < y1 && y1 < maximum) { - intersections += intersection.intersection(t1_sorted, this) - } - } - } - - // Check for intersections with the caps if the cylinder is closed - if (closed) { - intersectCaps(localRay, intersections) - } - - intersection.Intersections(intersections.toArray) + val b = 2 * localRay.origin.x * localRay.direction.x + + 2 * localRay.origin.z * localRay.direction.z + val c = localRay.origin.x * localRay.origin.x + + localRay.origin.z * localRay.origin.z - 1 + (a, b, c) } - private def intersectCaps(localRay: ray.Ray, intersections: scala.collection.mutable.ArrayBuffer[intersection.Intersection]): Unit = { - // Check if ray is parallel to the xz plane - if (math.abs(localRay.direction.y) < equality.EPSILON) { - return - } - - // Check intersection with lower cap at y = minimum - val t_lower = (minimum - localRay.origin.y) / localRay.direction.y - if (checkCap(localRay, t_lower)) { - intersections += intersection.intersection(t_lower, this) - } - - // Check intersection with upper cap at y = maximum - val t_upper = (maximum - localRay.origin.y) / localRay.direction.y - if (checkCap(localRay, t_upper)) { - intersections += intersection.intersection(t_upper, this) - } + protected def handleParallelRay(localRay: ray.Ray, b: Double, c: Double): Option[Double] = { + // Cylinders don't have parallel ray intersections (when a ≈ 0, no intersection) + None } - private def checkCap(r: ray.Ray, t: Double): Boolean = { - val x = r.origin.x + t * r.direction.x - val z = r.origin.z + t * r.direction.z - (x * x + z * z) <= 1 + protected def capRadius(y: Double): Double = 1.0 + + protected def surfaceNormal(localPoint: tuple.Tuple): tuple.Tuple = { + tuple.makeVector(localPoint.x, 0, localPoint.z) } - def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { + protected def isOnCap(localPoint: tuple.Tuple, y: Double): Boolean = { val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z - - if (dist < 1 && localPoint.y >= maximum - equality.EPSILON) { - tuple.makeVector(0, 1, 0) - } else if (dist < 1 && localPoint.y <= minimum + equality.EPSILON) { - tuple.makeVector(0, -1, 0) - } else { - tuple.makeVector(localPoint.x, 0, localPoint.z) - } + dist < 1 && math.abs(localPoint.y - y) < equality.EPSILON } def withTransform(newTransform: matrix.Matrix): Cylinder = { diff --git a/src/main/scala/QuadricShape.scala b/src/main/scala/QuadricShape.scala new file mode 100644 index 0000000..3baa4f2 --- /dev/null +++ b/src/main/scala/QuadricShape.scala @@ -0,0 +1,112 @@ +package com.samuelcantrell.raytracer.quadric + +import com.samuelcantrell.raytracer.ray +import com.samuelcantrell.raytracer.tuple +import com.samuelcantrell.raytracer.intersection +import com.samuelcantrell.raytracer.matrix +import com.samuelcantrell.raytracer.material +import com.samuelcantrell.raytracer.shape +import com.samuelcantrell.raytracer.equality +import java.util.UUID + +// Base trait for shapes that can be represented by quadric surfaces (cylinders, cones, etc.) +abstract class QuadricShape( + val id: String = UUID.randomUUID().toString, + val transform: matrix.Matrix = matrix.Matrix.identity(), + val objectMaterial: material.Material = material.material(), + val minimum: Double = Double.NegativeInfinity, + val maximum: Double = Double.PositiveInfinity, + val closed: Boolean = false +) extends shape.Shape { + + // Abstract methods that subclasses must implement + protected def surfaceIntersectionCoefficients(localRay: ray.Ray): (Double, Double, Double) + protected def handleParallelRay(localRay: ray.Ray, b: Double, c: Double): Option[Double] + protected def capRadius(y: Double): Double + protected def surfaceNormal(localPoint: tuple.Tuple): tuple.Tuple + protected def isOnCap(localPoint: tuple.Tuple, y: Double): Boolean + + def localIntersect(localRay: ray.Ray): intersection.Intersections = { + val intersections = scala.collection.mutable.ArrayBuffer[intersection.Intersection]() + + // Get surface intersection coefficients from subclass + val (a, b, c) = surfaceIntersectionCoefficients(localRay) + + if (math.abs(a) < equality.EPSILON) { + // Handle special case (parallel ray for cones, never happens for cylinders) + handleParallelRay(localRay, b, c) match { + case Some(t) => + val y = localRay.origin.y + t * localRay.direction.y + if (minimum < y && y < maximum) { + intersections += intersection.intersection(t, this) + } + case None => // No intersection + } + } else { + val discriminant = b * b - 4 * a * c + + if (discriminant >= 0) { + val t0 = (-b - math.sqrt(discriminant)) / (2 * a) + val t1 = (-b + math.sqrt(discriminant)) / (2 * a) + + val (t0_sorted, t1_sorted) = if (t0 > t1) (t1, t0) else (t0, t1) + + val y0 = localRay.origin.y + t0_sorted * localRay.direction.y + val y1 = localRay.origin.y + t1_sorted * localRay.direction.y + + if (minimum < y0 && y0 < maximum) { + intersections += intersection.intersection(t0_sorted, this) + } + + if (minimum < y1 && y1 < maximum) { + intersections += intersection.intersection(t1_sorted, this) + } + } + } + + // Check for intersections with the caps if the shape is closed + if (closed) { + intersectCaps(localRay, intersections) + } + + intersection.Intersections(intersections.toArray) + } + + private def intersectCaps(localRay: ray.Ray, intersections: scala.collection.mutable.ArrayBuffer[intersection.Intersection]): Unit = { + // Check if ray is parallel to the xz plane + if (math.abs(localRay.direction.y) < equality.EPSILON) { + return + } + + // Check intersection with lower cap at y = minimum + val t_lower = (minimum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_lower, minimum)) { + intersections += intersection.intersection(t_lower, this) + } + + // Check intersection with upper cap at y = maximum + val t_upper = (maximum - localRay.origin.y) / localRay.direction.y + if (checkCap(localRay, t_upper, maximum)) { + intersections += intersection.intersection(t_upper, this) + } + } + + private def checkCap(r: ray.Ray, t: Double, y: Double): Boolean = { + val x = r.origin.x + t * r.direction.x + val z = r.origin.z + t * r.direction.z + val radius = capRadius(y) + (x * x + z * z) <= radius * radius + } + + def localNormalAt(localPoint: tuple.Tuple): tuple.Tuple = { + // Check if we're on a cap first + if (isOnCap(localPoint, maximum)) { + tuple.makeVector(0, 1, 0) + } else if (isOnCap(localPoint, minimum)) { + tuple.makeVector(0, -1, 0) + } else { + // We're on the surface + surfaceNormal(localPoint) + } + } +} \ No newline at end of file From 60246982125e49cc841c8af9ae6d4c4e07f32ee2 Mon Sep 17 00:00:00 2001 From: Sam Cantrell <1220819+samcan@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:02:48 -0700 Subject: [PATCH 8/8] Updated main scene and README (Implemented using Anthropic's Claude) --- README.md | 14 ++++++++++---- docs/cylinder.png | Bin 0 -> 35631 bytes src/main/scala/Main.scala | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 docs/cylinder.png diff --git a/README.md b/README.md index 20595f6..e4cae55 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ This project was implemented as a learning experience using Anthropic's Claude a This ray tracer currently supports: ### Core Rendering -- **Ray-object intersection** for spheres, planes, and cubes +- **Ray-object intersection** for spheres, planes, cubes, cylinders, and cones +- **Quadric surface support** with shared geometry handling for cylinders and cones - **Phong lighting model** with ambient, diffuse, and specular components - **Shadows** with accurate shadow ray calculations - **Camera system** with configurable field of view and transforms @@ -28,6 +29,8 @@ This ray tracer currently supports: - **Total internal reflection** for accurate light behavior in transparent objects ### Geometry & Transformations +- **Primitive shapes**: spheres, planes, cubes, cylinders (with caps), and cones (with caps) +- **Constrained primitives**: cylinders and cones with configurable height limits - **3D transformations**: translation, scaling, rotation, shearing - **View transformations** for camera positioning - **Matrix operations** with 4x4 homogeneous coordinates @@ -37,7 +40,7 @@ This ray tracer currently supports: The raytracer can render complex scenes with multiple objects, transparency, refraction, reflections, and realistic lighting: -![Sample raytraced scene with cube, spheres, transparency and reflections](docs/cube.png) +![Sample raytraced scene with cylinders, cones, cubes, spheres, transparency and reflections](docs/cylinder.png) ## Getting Started @@ -75,8 +78,9 @@ sbt test ## Project Structure - `src/main/scala/` - Core raytracer implementation - - `Main.scala` - Demo scene with spheres, planes, cubes, and lighting - - `Ray.scala`, `Sphere.scala`, `Plane.scala`, `Cube.scala` - Geometric primitives + - `Main.scala` - Demo scene with spheres, planes, cubes, cylinders, cones, and lighting + - `Ray.scala`, `Sphere.scala`, `Plane.scala`, `Cube.scala`, `Cylinder.scala`, `Cone.scala` - Geometric primitives + - `QuadricShape.scala` - Base class for quadric surfaces (cylinders and cones) - `Material.scala`, `Pattern.scala` - Surface properties and patterns - `Camera.scala`, `World.scala` - Scene and camera management - `Color.scala`, `Canvas.scala` - Color handling and image generation @@ -93,6 +97,8 @@ The default scene (in `Main.scala`) renders: - **Right sphere**: Highly reflective metallic surface - **Left sphere**: Matte yellow-orange surface - A red cube positioned in the background with rotation +- A blue cylinder with closed caps positioned on the left side +- An orange cone with closed caps positioned in the foreground - Point light source positioned above and to the left You can modify the scene by editing `Main.scala` to experiment with different: diff --git a/docs/cylinder.png b/docs/cylinder.png new file mode 100644 index 0000000000000000000000000000000000000000..39d7511f735e1a29561d1b34f01b40f86b24ff4a GIT binary patch literal 35631 zcmd42WmFqq^e!CS-HTJKxI^*cPLbg5T3m|L0>xd5w|IiPI{}K8;>F#)Sh08dyZ67= zdq3W9H*4igW-`e-XP+(4e)gWI_iFN3=&#U0AP| zzD=>?ooKziL(XlKbOrs$z}^6XuyNvD(ou51OHPb{|3@2z$)epT=>e(Q&&Aj_492v1 zjM$^mO82W3{XBc@6H3K{2DjUsn7gxSeDn74H1Q1diD4i#{#1?7DAY=pH^`a|)E*Z& zOV9MJ^zLcB!?htSgpp0uk2pa-j)WzQxkYA}`$4r83EVy17<@PZ01n@A=gH(;CP3D0`vIk4Xaq}+=zjJ)_6lc5V*~DqdJnhYCzhxTudL)y z9yW6{y`J2Yocz0u_PVVv9Wgz1Mn}DD9;b=Erl`n7B++l&QaOo8SxHF=xc`5~|KG>| zb@{&w4x_1K{lGs7hg}jYm?VX6Pd|MT#_uhIYa6@99`LNpT*#!=&8le+h2my3HcxpWx=;IMEp2{6F!6 z2-95>cbEj(OGQ3OJjlz-$ZV5eJ_9@9CD(3r=>dWd1JNv;V8D^l(D9gICczcnm<}dt zm=ThBIqCHh+Z%h?z6#jLynkExDX+DRxQWVf+{7K9mtd za^7a?q3H(?Rc}RVKUOlSPJ<+A!rM$%^`DO4-fF;1(WBAa-$Z|k%qnrS(NAYsN&u8E+o0;Dd6e-NVhE~R z5ZmH0t%T|{MOD70<@hLl~ z8?CX`pV2o`NrO-otbthO5C`=Ob4b_!^nTN|my@Xjm3Dc(AT?cyK|@99&RSh-*+L zN7+L8@Y>$ooKni1)dozSF3(0R?EtT3QL6{hHaItxoz@07zO%N~4&}YA*TObacs<&f zl5Nb(%R6xyU2y(EX0fcuq?wqQ1|G79Be1jGc$ykY6$958;xqV$o;W5=xfqFuR$Er1 zJR)sVNu8T18k(ll`njU>o|AIyi9tb3cPpc4$OA``?OCw2x zAD2G56CZ49JWnL60ZyBN=7DG7Uw3@r^IS7(A_5876FrM+YF2;5z%azIY*gS(;Q!7X z#$7>=eU8hgA?%_mLx%?jp9F+UYV)TV%(^R@w!c|#z}M5P_BR82kc1}I#M9C~3aupx zZhSUE4B1aYg+rE9g5hhX=*y{S;f8>sWauKMetxAUwFanHN`F6V#xOdUcO0gZ zqoVDx;FHsZ#DaR26bFA!C?bLECRrCvQy76flA-am@ z9wdc_6JIih4LiIQiEZigpv5X5KSd~zuDkfpfRdVW`{bOL&y2xZAAb>nX!`w7E?Wt* zO2F&j520mQfIJeDlR3-@sLZTib%!_g9rHvNq>sY<BzqV4!h*8A$?Ed+UhfV z0&a%{;(xo9#=H3KSe+IpqXf^&SB5*_+V_4JTrVKE5|aER*Y& zVt>@pI>$`N5;}!dom{@vpc%rI7|I2ik5gVajQugr9|Q)E?(pD>$T#HaGNTKE(h0<9 zzb&Qv$UVkYMC72;=-LJ=|6qg6cPK|*$5ziJfvpqO9nuS!V-tPZOolT-KWAst^2 zYTgOxDAz-qPmb}WoHjeo0?$78a0QF8$MQy^h7F-)9($zvsT%0TKliG!$!rkBx%bC% zp1o%yvaBTqE7F?!D2fk$HGhR=FUz89>Vm%kfk3obW^J~~YmucXz~-1`u(+0`dJfhw z1slcA-H^r>aMnUS(r>Jm_iP2$Gm>$4U-_Ts6$s%K2$5tMNqF+qcSbAX-y`6CeoKUJ zfh58_dIO&kNW;3g@+kCG{1of=*u^gn8X zh1c7uiQjMEBsHU=6`(RB=xbIhX1FF*u+8I#6>BQKV{}bbp#NI@nm^Jp`TDs#pCrLO_R^m&v)3MXKRu@tp^%{sZ*xNCvy>OymRKs#cJ&q)>@)Bt zIGO5=c=mn;t$jw~Wz8PP#>8YR1J6qB2X5p^(4RNY@~Kv_u{Wj}=;hgZly1nPnpu}Z zp~Q_srVg80jVMT5gM1lN zKHedQZ8vfja0nIM8uJp)A(gU@$!hF%g6>0~$Fl#KlFUqk-47 z0q)Qs07Xh|Uii`ql2G{$zXyYrAW$gdw}P3XWIB+UYR+hTm$X6)-?p~#5!$z%+AWMd z0l$sgUM+QgmDl)D-dA{cvc(Ck5x#Ye(o@%Ll6~~ zVl!v-`v*T`9oeHnm(K@C2-&o{@Ig_D-Ht56-yiyejwY?U4f=Lhhhw~x+JH>>jpr?= z?0kGI?Cc`v<8(~}y}nR-FPML!{I?Db+ksKwtQV}(@rQVe^P{3Jw=K+@XbJdw;NBP} zq(7i+%e@C~jc)By?kx1>HlDu^no^K`*c>0Y|7AF~lrGD?*2ESMo!<^z;#oUeO8?01 z241zOI@@I1eIDWLe!RTg8HHO9Cox(7a^b#l%W2y62%MBPQ-kh;-1Il;SK(Jv&R1O= z^d$*Z=Y_EORFSk*rV&978ZyYS5Efd6L{Kpy*(~2U&j^R6T^8~fJp7H3E+X3~yp3BB z1d&Y%PJ!(e``4Y3<5NUM%gGcUUkG#rH+Ppvnx2-T97Ozh)cx!zQiIx*K+N&FLO<94 zv^7?`0Nb92B%WynKp`X`kV(tp z(?p73dwzcXMvw+V?)-WusqK?4c-AL)G*07~ghZ=4Y0fcw^(nWF6b$zHZD0EG(EWlC zlI#5&vij^WyX-Y@3}^QA_Zp59Mm0Z*BuoJsE98iy_j@C)ns6RrmBk2h$g+7AQ36Rf zr{Pfrhj7a{yh7%Rj-@LMkk%rynqkXG)KG;hn&DGYV7CGQCi_ko;S)n$}1=pn~Xf|o;C_m z?DwOp_9~xCujdDEhcua~73Eol7E7vmJS&x26S@Bt;1U=t0cSrrM zAENR?b?T2_t@tV}seYRnxiv-k~Srgf?kgjrv#sY%V%mF}2xD*~Jk-IEqm7_5H9s@$Z}qlfmnGQ)+R# zIsM$==Z}#Ar}o+U>DI3z^jU~JT8#u!0xya)c6Xt)FtOX^6c^E}G2SY%E|F|@oijZ* z6k%{PNkbYO$mRFM+{p@oQAMt0Ck*!jh|wZz^-CYxqkK9=5TcU=yYI(A=QCRBQGjeX z&Vo$lUoJDcBcEJ8jv5Yl58a;muBz6a7Qx;p$f%CS?&E>@FcEOQjCn__jpqRkKpu|O zRGil?H2THgXv6|<*;YbRCXAji&K*-Gup*$Vet8h2X zVxZKY=6K8$%f3;vr)~Wc#+|*aN*sW48re#d_<4neZ!SkU5qehr&)DMwFMlw>ytN)k z8rr;FgI#%zem*_QfBbt;3Y+M>`K|24xg7M|+NF+Nn-A*gdN@6*kV}wwZdt(g9lfqP zss!BQXO7+7QpUHwS0g^X6>FLy}K2AG5R+a{^7M);W2sCI~#53Ho#-X`-RGCe<1G>HKKilt;N8@iKMMPVM4{-^8SjrC9iRXPSSce-5BxuCN z>E@)x7WU$T`D(U#!O!==?(o=bDPBsu`Y&9u9@BAZ4B6l%MFf2LZ=(7V330heYVwxl{uR*5#X*r}{YHb@vX1 zay#G$#&OV(6`5JgX?U6`J34`-i8!ADzzrcJJ8lYy(8ixp4}^{x62&kNqS{RHpau+|Gj)Hi!zx1Ra9Bl zpe{*HZmt%g<^?)APU=Pa1$+!{0TfzwBs%zmlvg!HbA%K_onTYRuH2|+6Cq}L#Op9R z9M&mSZeg`!Yhxqev~XS`$bK~!x1L4NB`MG1J&Ls#f`IKx`Mh74fc)ROdY&K7&I0cD zBwiN1d|lz@+gIT?sHw)qnL0wM;O1z*P;9dN{V9#pdQ)N4`m1qIJ@~Jd7E)ivhN|D+ z^_VCqL4i;J9b1Ny9Rfxn9%WKmbhsY9CL3`99wjL|F?wxeL@Y4|k{L12t66@l2JPY3 z^>l3!Gb}`x8pb>1c&ZyNvR;i}C*8~-h|%|N8^-iz`=D^dAWw$2%LCOUg)q(tDD;qf zsN-aU`jLiIvG+#-hZ&xfr}5e0;o-r-!Qml%h}3s32??viH5u>vYqH zpD-q>J4M9OMdWy<(5wX{IdYwUH^>ZePUl3 z8?$15NA-SCUn~nLkaVuyRRvq((G}#o9*SZVeixMSDJJUXmKvRjlS2i5-1*XS zmUx|CCuLt7aOWq;HCTv~%rSi(dAWrxSR}R`77v zu_2ENw@ko!FG?p2TjZGQOwHv-A`DyX&wH0z2_U|%=B_4p!TQ!0n)MW1AKc&4cOeWL zY6T_;A2Pf6?f^dKI`0(FuA9?8?2FnP&0W`(tGNUZIqH0!PG4*FI<#5fB-x0!D8pe= zdAIoPW-v=`vxdY;+QJLHnbk-+^j0G_D9L53o;K&M`*4Mx4Cl57phYt*yB-RaWmUBN zG2uoGdDifScA26dtbGXOJgIy*w=p&^^~B4MfCLx}rj>cS`_+;UtO34`7dgf4!AG#} z6MXiVcq{Mw$@t<;7in(t4U(?aV@p$-o}R`L`}1>3o|>{tV6NO!p3*DTXD{$}g{01^vk!0% zMLv5k&u1@x^JoJ$65oGls^J{y2N}$KIghQr72bF)oxbGQ;Ts44X^z@ zGJW}b-j4%-z|pI#tAI#+VP0+NTl`hmeykW!#npsJwTa8;qxu|g;#+j(V$D{g=f%mO zh{%a(k}h}TjNqlE)!p`&+wPaZ?C>M85V&=m;5+Hg%Pzr=H(E5`0<=)8Q07V`$GX+D z!muJLj6+ueY0P7wtpR|fi%IOXW$SRZ!|w|s1(EA8RmQ5Zz#Cen4pt4eOPFdcE7j!Q zRaBJ_4d8cYIi#%U_Vf0mFaAs|FH`Vfkex)!Ch02xfqzTKV2$9XBhz`2v}UE4tCwBD?h7%iSt$5=Lgy^e4`(Z6ulAPNE#*j~5eXM%y%DJo zS-QzY3^Jt3=>Xff{`to4s7m-OU{3^rWsNiFBF4`JiGtL1rhWCgDkS0Ieli#vG%E4@ zRCQ`nPzKYi9nCu@z_<@6C}+*cR;Od_j4oG|K^3bB6^m*;5YQ87Zm-OQG?Y= z0a^r2G{yb~d3xvMbzw3LQ3pQ)p=UY-AztG7F2v_^g4jkF0sLy`!)bdz#Y3|-6CRie z{fwcZkUL5S9@pc_BV=JE_iKLcPS4?inow-TE3-v=toV0tTkrMv@_hL$Qqkppz_B^N zH-5Ed?(qz#;lK2#uZ`RV&9Q=t+S~niMx+a-zE(l{Iu2250Mp1(pifp|Y+mtR+8NEj z!NDOx56>_Sx-ZT!$@=2G1ovgUN8#^b+GSScs+%2#J&!V zngbb<2$5&;F&e}h1Vj7d)KRC$C&PK56>=X+T5)oUOpGSyQRab{A4D9TT^t54N zjgY+cW)iBgu`Rii6THQR@Jon%9y^_@GCOp={}Chw#@3>TQNfr}7=aib~X=25VBzjvb(0C_C!QovU_G5y?|9r;@cY52A(E43X z;^_|;EXVjclw;#WCdT{r&+b)t-YS(z0x)?z?!`4hu{2f26s=4Zmb5V&MifoR*ft`M zK%$b(FM?q^XtzXmri|Q!zki~^wASKk9-^kBjRX4E+1yCoT7F(*w4Q9cMQ6OGFLnlqb+5+g!g|PTb2gryK4~Ey;_a1Wf4H;> zLqcE`>Iwo3;>&g_fpoyu-=k`ucobK^V55J#%Rb73SqK2@$w_rJHQ_z>i$etJJ4sR; z!PwcePi7%~cXUQwqsxBWGvZj;jU2`THe6)=T->BF6IWjb=$KyLpMQ}!UM5;~cB3Ty zcD}b=5~iGhQ_1m)7`L&xnIuvQ_58)Aq!h%Rreaf{8LG{bqK1QA^ecrx8UgYn3QbZt zQK^K92{~EON{3M_^Z00Ij@SpMw6v~s#khW2OEiyPkauN;C_6iIaO97kU8aJn6uSc% zJXkaqW@|xu5QmL}18|k1ck7>r%dWE2#jdqWKkhhO(JG!K$LM37Yj0%w7*jlZw6whE zL|k=!(YMXrao@>FPpbAuP2-N`(EivB1FkWY{9#Q~YBl0$!4NHCKYOfLa_JV^X0QAr zl>va4_(Uep@OSW}Q;fKQlhnykw6(+PRD)`mWC0Z@LxYoh^^@Wv+mL>Gx+d4(FL9J} z(-Pmx95^zXAHL$k(JZdvglH{}^+>YtW>USP_?kmO{fYt@$bm73l$?T8(x#Yc5GDDC zryt$y+7Ms|inq5nD%R6Ib}l{^HY`iiEBqp2fF?|;sym{S?i3K{iIQVL$fjpUBBeg# zTh4|4PcHzu7NmtoNN8bY=JiZ)#Af%4>pzQsu_0Z zN$3P9a0Vzs%@qLKZLn9qn0S4ejgUJ)!xoKY@eyv&z|z+qP{c*?xH0lanKYXChiF2NsM-1O$kR zuo#~ZC^Ch>vR_)7z_Jh+hxZ5W_J6MTY~L+NvZUov$SZ06=yLL_>#nwCNjW$>$(yiA zG!R>fT0C%4FL3nsgTk{ZyDB-!cr|iK45h5Rt*N*l>v}i=!sLJjx3N}dlGFrSS+}H` z0?E=Mym1aNo$I;S_Qa+d)|>6l4z2_~6QW$Lzd%tmL<;re7Wo?UoR<9%&$Y9S>Pr5( zq*Hp7Dp{23VieYog{(7*!Kj;jyo&)3(1Rjx~-L-^lvb0;2e zb(ssK?Dia<9OPw=ER^WbnGBDUH)ft%yoQ$BWd@w{a_MAI?UkERxDDV@=s}@=uVYH{ z{GjzKe)qq$#jvTJop{j~Yo6v2tUJ{tlN9irz^yETmSc+&uFGyiK;l;4dwv?$>K$vSQ;zTvE*?tlRN6R`;U2<-kyF^>) zlUVJ(h;wPU!2_KS4J0l19UdJqMN6?zkFrQ#eVsfB=nR1~fi`IXybaBknmo-6WuUfL9*d7-DC06o0KF_V#AM z686=0jmj~;(r?UR#7*d=VcNh(wf?##&+t{UPC!Dre4R-^kI2to*72PHcn4^*?!U9u ze5zX)(_B@>E;MujTTa9=sxa?+>xT6r!Q+|Bpk5nl?Nx`l^Adh=oNOWLiy_VC??y** zg1s&37vFlpKf%v?!C7MT>smq9ow88_{twAltvQyBeli2Sp>XhAZ{KqB@FDgL4#{Q2 z#>P7TZUHR8$#ZM?{O7`cNz(V;i`uF0p)=b9can7yVsDcU_q=r`wHa6Dm*Qr!CT#rL z33#XVi-_M7KwwU4Zti4_Jv>QMLaA%#5rTiNuP+u!bg6H>ZR6XUO$Y72_>+5UB~+Yq z6V5X=A+TK};;5HLs9sdmPh3*PM{=m-c>lKVC`=qO^;!LB%Y7uNHHBuX#bH(N!Xke3 z3n_3Sc=qhP!8fou1;Acn(X!^Sx9dX}AHcE)z4l)6HWpjGO7 zwZy>~j2@J!@+uJv2MccfZeih9(#F)3YR`T$!)wcIfp|=ugt!meT^m1pdbS4ud(6xu z-Rpt3x!CtN#ks{I5S7JX*(LIjtAkR8Ga4ZCjCd^HE(O0-`940sG=BM-@7jk(hc?>G zxoA)BZ|FO$0G&3$z^SmI4uP9oiWqZPpzP+ZzbJV8e_S6_A~DEd(fw4gWJo^es))7K_cw2Jp8ZY_hVfCn^lg zGV$Wh@xqHAMR|T#{&H{CW+TQCIj)JUvd>~fmh9XUJHs=&hNTN~y~+A8OlqqorldN> zRm8tHS`neHZJO{)1UY2rv+vK``7=O?10&3r0dC<7mEZnn9P#~JjB}t~XyfRmGR8Ee zz2rDvhtR!R$;IZ_dymz0`AMRUUphhJi-Dwv);qCFh_W3 zDFj>pa0X{~UQOuiQd~N}Sla1{+L)R;_bAA}kBor%7E!f9#hAfDyu!WqXXVt!#>SLt zwtFiDPn*Lm3m6QmnFK0>Q<*%6RJl8T!#of+)3ali#cBRiCC&u$^qbp64Q(GcJ?Jh|dHj?>^f8PK(muQC@u zi2$FNmm@jA&@T-DxDd~S;oYV=K+e%j?Uv! z0bzxE^T{MEe6sqYt*SQyT(hxzNMA|v&8sb^PetTwhclKk8 zAgTnpkMqwr7}1tH-t#3Sr>lytHdECpwnsNQ?T5Y&EDE1=rY+dx`D?&C@S*LXWVg;& z{OLeln*8r3N%umZhDgo>CI6~4VH~qkaB%Xul@ENTN<{S^eo;|GyP+D;BCuNJjD=@2 zBt1`G*B-Qr(C01{Kf1;Fp|ZFCFoZX+gR1Q`ky#X1`T?dT1o3Unn0wSv&CRmMq;1|u zWHPbK;n)1C5B=27A(3akLn5fO@P+|JX*3zl)>k<;E2v;f|;1$=NPJK+A zLM_dhXa}-u8OFFXKFQzsSbYjDK_%_!@{`LS{ozGS-~t%$*%tU_LgY;JpeP0RLSEwU z6dF^G>+On9wMpl1zVS7H>H60Y1{lXX)cb`YapFf=gCh)gmTybKD$@=_Kg(^8!sQi5 zmTLk_RlMRH| z|B8$Z*~+p|G*T*Cx@y7fJQBqX~R{0m5xz~43W{ZkCXnm7fcfOKQ4x-U+VLp zoURXN|Al6m!$+VBbG4nK&NH}q(IP0j*!(*H;L2R;rGhpI^6kc@k0UElKyY^0QON>v z;$^C+rUCO{nz5?%e+295rM~#f{bch;%KV4ry*|PRqEm@{&KoAJfCCB5%;q}$ev8-r z{UJ^PPJ|!-1bPy19fIaUUI)%#u`RJ4*LF&w)Ue2<=Z3(|(c9Q(6&sv~M;E^F3ZTHL585%864P{H{jDpzTH^g=Dx0UdDOpwv{ zXt>nT?!{7crEBzOiUsWD6F>#OuHG{1n%k0H%mLfp!^)SR6tCWWe+BgaQ36KukNnz- z7l~h^^X`t6 z#+XMmb{MhdZt(nP&)otkS2>TIZ2-+;!puk!(;Xr9;u!reXcAH*?^q;QyWB$n6$1kg z)U@Qi$XeY|2PEY7SvNox^n%)cXC&<({k;JcO#q9%LZUuvKNFV;09f9^H;lV1xE^LN zf9Jcsv5Aa2{TeYOuS*;}4V*cUCUMBPh-~9*fJzVu)I>|{BNH#M+F=zXCn?b5M|E6| zuy$m=i3Tdak4PYF(Q`>z4XTgZNolOQm)GtEa+_+)!w-Ayo0q<(RCJeKD1dVz zm3e$R3!c1a*p z(O(#e>9Q01-|CmY)$!Uk8E*3ihR$ocSX;Fb-A(a57{8BfyjZ@CrWt+QN!x@0jN@Xw zkh<~rwfKbBbx9kvk^*s?iwnUuWYV{|g0)$lPdpxDcUzY!4;j7&@bJ8Gyp%{Oi$wFlHw@NnO* zhagY|{+arWExey3PxW>?Q5u!@lSLSA?dd6=JVh@K-e?1!l+h98MdtS$#JwZVs72NL zpb7E4F|GHu&iJ)0DMH?}P)nF_L;w?K|71TLJx7EK2B{m}YdTZUE1)b93>4;oy425q zu@mrBTy)|U6RqV2)0w_w!jJL| zB`OhXNa@;27F?|1clR2w+<^-E6)j>oN}Liig67>x<2?^I4UP!@bXowdOZUf|yH~Jh z?dWsJ7gi5vphYPsCkGVK-~TxTH8wsWj`mm5cUmjY0H+50p1M#Nb3Sc3vR)sn%AphV zeN|QN77iN$HJ%Kv$z+^LAdR1j6row^+q{l$)`Ej&0sxl-S|4`_y3$e? zYXq$P3DCX0!l7p1WAO7;zH&Mn88vk^$u-GQpnaB&zRuS_uhyW~#hig?!ll6v&A~^g zKu9PzeR3$R8p+tLG}e=sa>+&SS;SdMukK0Hp54Xo&A%VBgZAIGx!RAwC6V(IYlNG-TsFx5Buz#o4|(dpiXPm3=U(K;cK9C z;U5tkT_6fLi;e?)sT&{xEzJ+g5|Qx-N-`q^&b`||tO!4!;0W*2 zboKU&8GWH}M{T}tc?Jk{;iV1_)YX4bI=~SS=tSXhBPN^AjaY zkZJ-og)<;9(*_9Z!t}ZV-dY|5HWo0Qy(;sDl9ICWOR3*=boGF_{+xA&@thiAM`J*b z-{r1ab8O{5zzu~qxniaMTK{I4O)>}BH|*pxy2EgU*K-!n?U*MN$MVXbe6|ob1<9&1 zYQgJesvv{l$mqzaC^pFnh|FjU2`nu|tqI9ch$&SV-qwU@h{L^^v{$&f;c`&3oqjSv zHPb*~CjeC#r3Z5})fj+oo0dL2jIdTabwAy3cK-PVG;6`&iI3*1_k@-J!v3E_J_T$c zED;ddUtqg=L8#2fowuuDxY$CV1t7e^GFvtJL+K)WeCRfV9!~a_`yaOMe0(1-hVabR z0&m`hFy;BegKkzI*dzZn!?@MJ_W)WN8VNLCtm#zf(K0UiKi}`&zinO%@*iCI7c#~L zmbeC)i&Iik-u2G>ev{pa-wdl`GymMY4F7T^@j@UBRp#t$KoUOZ`KM^P3F+S3${e=) zl~16LD3-1hW=1IZ@}1vD1jd=|yqG7|sN2Qfa`A~sOy&>VfN9{lL{8%|@tBwf6KglV zzca&}LeWEj(9a>I^XKON0gbvB#mqCL9p~`3rQVCyBvQkGpqhbpD)^qi*GF(>uxKJs&&njw1(mxMQoYh+dU*oW zea0J~fNCK8W)-VWMexm{vG&=J;tTsI2*`j+$MlJk?yCwb8MWbpI!H4)$u@2)?u zDG^uBQk(N$eiND5#cW!JUs89v8M(&%;Oks~GCkizF;j?2WH?_KtM3qMl>c|8RJe~# z!-jT!7O}?Q6ao9r&qTNTquEM21!-PxHp(XQ+RoyVD|1e4lgkO4HwWk>mm&7HqNwKPE)sf6sT#b*&JZee#wOF?Ka{ca1FjJvLTx zrGtfC0>bm%543?`SP;}iaMb~l&K`w-_WOv>XK?-9YD zgZ?bpxl!GLoTYiaL}r=|A9Y0*;@)eK9qN0sFK49hb)wJd0%-M2oil%wBn9Df+Bg4V zFK}J$uauO?F=S#(0s_k(JtRHiFBw>D4#iB)W@#AqqcvsLL@_qN)HDe8>Um{9>+{D* z@D5P~(Vdzgr8Awb_Elmegb+{Q;_F#7=0?nK=1#P{_Qa58Jy#a}ur`FIM&v|bIKk{H zQsz+q5t(DZCuWCC4fch~>_nqv#U)#y6SK}y%A73oFnq;8Af%F=G*C8;t z#q73T)iPe(st>n5lTY1I(n|U>LJu=tb`B}Z zATo;Pa?NJS4Ztf5vlXQp>IE8)aj{L;l-ZL-{IFHAxgzUta4+_w44+-r2r@URt}$VC zc8c|dTFLj2MEDFmS=r?lRl#(mvvfC>Ohl<%1ZpU|Na;8b>-kg-zYW&mo_<$eU7f^U zy(f;KPE3rdWM`AbGr`gl{h;n5sLM>y;ovu-tE-3j?COa_K&goLBt-Z9Zh5_nbV=7K0M?rA{vLBkuMyHq zMWvUdFu;XrJM$*sQ1Z@#J=SZ5t-;}o{%+$Z*r1Y<62}~w(^D)g_`08SF`XGhM|e@pp^^b3DZ(;tCt5RV+!!#^56?4h8N!-t zOD-&M#{O8GEsaKBMmf84cv(}QNGD2(VJ%6s&+mitcF{2^(oF_^D0Ca%Q z<3#rcmJ9BKu_EkUkAu)-X<#Fl9^^9R8>c@q3NqY@8$BJGovU4vJzBNo9?*`$q}kmbd(~c^?_&>%Vt$i4k>K zSG8;Rub!m&`TOs4Y>HE2VD&^OP*JBfV8y`#8=Yk;O}T+?g(-?L>53|JN6`Surmn<(oWl$ zp{;PKcgNKk_lxgbiW~LWNrsvZQ(-Hw>O0V>jOUYv=QFzg6o+?KT72~y6-=eRlQ<%P zY!u7PqG^=amcaZGAll(Zm`T>ZQii``RcNCp!55|_600(#0A0KSHJfB7+9?K+CzWorK8)UC0b$)XUkw z=o?*^=$sAXG|=F^+w&AKKkkpXNa|V@pQ_OOK{&H*zUX4bvj|NQ5<-o2OTCYX`0Jmh zne^SXC>kUK)-381bi&1%_KT=6A{O4hb0Xf5DuAvU_BL!nGa`>1y&}f6FjCTXSv+>q zbR+UV+K;}pitLmBRnqHvEb%%=`Gl>;xs}6Pqv}6Wo-KemnNgv%S*D4>CSw)yRk8H` zCxs+XV!RxfQt|ubLkM38v69e?9vdlP^53e^Zt|UDZmvl*m}&6ichE1f&ywwL)eeNQ zR_w;VQj?mEsKq7Qno5tjH4kO0VEnDJSM_b&Dd_AF%5gQagX6dL2IGV(LO;d&`vW4$ zpN!**+#OA^O+iQRMhv#SKVy|EbzJg+w`sjbQH&_a_W^p|)nqz!`aN|Ae01(+Znn5& zpIK^1xkk|{@`PA3*y{0nHhFKrq1O7ax~K{Mt{ju^D0(;AylI>=Qo)VmI2G|G2=@GF z{r=y+f>>2*CixSF`|cxp7u=_!aitnic}J7=O;?f|_%$ z?boq-rYT?to^Rh81wUP{KsJ^=8JYKNl;P(#xmbmG7S&p7zk^7O3){gF4uOFdC3&KTg=%;X6wOi* zSksOXl#puL3dT?o)2%bD3kO*}L8CGbW+w>UbF5B%!v=guHp=x)uYRFtsXV)uC~=nQ zGSl;P13CX6pn^&XqPqyw7y~+fF8+D4$^W`<+)oF9xY*N)gAUn(|QW*sK(7qQ!U-XGvF{Bo05$j zI|P$9TAr3zXGZ)eJtaWX53nGk`r&Sq+{iQ)8&4`2*9;;KX=(&fhY%WKq~)3IIiw2SNzs%^lOzA!r7>H08vxlKHVke% zA4#I2lVjr_0p&`{*2M}9kA-in%k&hLQf3hG43BkpV(|BiZLNS^t+AIokxrnIJDGtu zPx*VGM)Cc_b+>cMZVLq#Coy9cek99M6)PlqxE5()thdaZCv=RTA^1zFg)d*5sIs|i z3Z%z_mInsH&T{AR6+w|QujN`}V}ETv&LOdZ?jH9f*3QE!v5Xz$4Mrz>h_UUlz*P$= ztYLhKuO!VZ&UrbL$JJ)j6t=oy&0%j>Q`RY1;<&b;%_wIzl1Ci z6E*xcawpTO8VFB!nF1KToUP|0dm%MYkCY5ZHFOnqu}U}4!b0kV+P;_w=FcusI>}6) zw!S}C>IoFX`3N7EF5dDI6BruQ{|I7VuRzFwJ|6zC!bi=_YZ8glf)LUnc*b$`nr$L| zlRrA-k5dUZy%r+!MESF7?kFC&&n5#p{_(vjOl9hP&xoSN&z9Kv%0pgW{&VK_yfta{ z|MUV>24bawWX&*f-!afvC0S`N)|x)q@=|+lgp*t%ejurwk@=;+mROr8XoOk2j8A1h zZ{lBx8j9iri3%4LZuAVWH1mp9osgaYp17FM{Q!bbR4GA)sO+D@P0h?`i%{d89Lz)B z<|;15&AnsU8{oMvfNXTm1}!##Z@(MX^yac!%R@W-JxqhchKP|@wkCQ zzG`JWuLYTjCSnB$!XDtE0}o%{mT<@sNgs1|ULN+Yq?pk^_6?&pC~Jwq$vc->_a113 z?Z?nXvMnjqlJ3XSPtP4`yD1Tct7!WMy}kw>Xe#~xX!`27rr!7OF}erRB_JgsjkI(~ zNDVfmyFpS)x>FjYLtu1EmnhxcNJ>gK{Lbh5dYd|_kDEEG$+i| z0E+dMYiLY?gQZR{c4O|U(bb5x<|PBp?LQ1!5Gx%)$WQ*s7wVyZZUzs7CrMe;+@{s# zfzj31jT+CNSM^y<+K7=bLf?I18-VYZ0KnouUj$$~d>bOw%@r+6$@l`qzAJ{)d{M~4 z^xYnV*FlsWKot_p9soH|jbIZ#Weh`x^Au~YAZA@_)jOd?71PZ+&xgUSgvpD~mkANI zf&~MOs|vGH2Z$A=PqGt4t@PX|+L){mFe^~ClJ_lqla$cV*Q&#O7+r?06SAoWUZrIN zB@qn52tx4I4uzS~>&OHJhX26tSFwU7Rqa=ieI{{YS80_89|Ju_-oEt!2oDkaBCbTR zr;>ZTo;@7)ACN)6DpntK@Du4x@cQ^nA946E4yl9sIN^yl7Dii+9g&*rIGS0 zeTu(PER5Je=TkxzNeY?dvhqr$+DfaADnqxSa|2{UyXTYwc52Yem(MryOo`k$1*N+` z!D3qf!7N-X7%!1_w`|ZqR={9UL$5#Ka`EzJDdXM|fb{F{mM1@yvcB|_W&sx%aTg}R zuvkzq=^i+1o&%*M7|nA2G)0*Ylb+@bX!QyEUD#wLdo?$DOS*^aHqZ zHxG|aVASXAxG~97o0syk7QY?%Adtz_1dojHG?5-f96B2F>#?s(zq}i_Xm?w6W2rXz z2o3A;vtH+y?0jImhoOGusLx_dC(gCKDiDUqrs*VSL&{-CM(=7Qpb|O?T75s{THH)zk|X50 z*|jq~Zh{^$6b{oFzk-kojlZ-?<^2bv@FotFAaq%X&Tf6`NM^x*j~xA4Q@@SK)q*1% zB;IgDdp0zyL)|coi!Cwe2DA`_M45z5>t##6dYY2#t9=lbGKQq57$p#sPz5J>nfKOjQrU>G zM7>i^i}!Y}(2>^X0OuT_e|-PiZB&J96$$49w`wKV{3Mtry94{gP8)d$&txH@01O=( zjgym5$H1rLLc>8Kqb1aW5RlPfNrw1K*=-uq<^IdUVksQtltqOyW<+M75m5h8H!|(a z%$DC1D!1>f5P!0`IroWBIzUP zcbB~XL&5FJMK&;|%IYgFHm#KU25<{rmzD;Dz}W4UD>X{W*>PGv{t8LZ3QatX(8GQB7?HGOIqKQf7qRC6@+< z-YY>RpptX6`O+s3Q@r|Ytkva8Img+@%RZHF>iV@avl@uW;cweG(#OMha-lPA&R6)e z!#4KcRw(E$?t_S-AN{BeW zht1VeJ&~dB>j{~9s&>}F@c`KFhF$K`wCo&Z=|ir4fD%Bd?V|-fR27s_1Ch_JW9CLE zyv^}Q#|VaI${X8|GhLBpXT=_Bo#H-@u+`okmWNznHIZ28d$`tees`1ADol9+8?{3- zOpUjUKl|wcEO0DuWG z1mvLmDZ7#&89kedCg;Oe8Hua$D+Q<>fz;tY5)ebo^Q*!DUb3WI_QW+(*t4&UpJ9?! zR~l5Q3}L_hQl0^XMHl=oAHyn`H%f+>a7sb%mA-Hu?~BSfO7iO8LS+$OPObY{I7p8H z<6%+IAlv3exG()i)iX!$T^Vl?7bEHMz$w5HR_eD3(8L)6J#JeJqu_-I2Tm(N~6>Q6kuSukRue zH&ck>;(PuN!5lvR8%BDHGHx?tvg{aP@>di-y_o4M_`3700JJcY_(s$ z`SQ&86<4Uq{&=W7BzGnZ5Gnsh7yXIiJU%)3-@^ki0sRLJMzU3|>j&G;mcz}sX4sl< z1Ka?4)+a4g!vp3kE~I}uV&dPeiC>Oy-wMqoOX#%V3QO+X<5Qi0y}nn_#q=5jDsdo9 z<=eGIM|Lq<+wHtoYUQtjmOs}n%{|T*MDl~e_<+xc%R1Au)8U}rwRHFMQ}?jh~yDwf)zg4bD$04=#dfqla*M>#-& z9Rak;xq*56+1kqHi;R7FHdj$ug7Hy!mGX_jeH^swXZ0N^TUIpxLr(^8Ymx?2X2~L< z)uop}t%E2N`CK?wG(4qj6V=v7!dR`~K0xb$3?a%!uHr`wqN3TvPcQt zyS2k*bL8#H=S|?9eWp1={TvX+u;q*aum9%HAG_J!0Mnq;@%$fy`Z%sHuw~`s{|Klu z!2UooK}YFQEzpBgd+p_coB(5QkSS6-K(1V51A7f9#O)s#P;VW*PpB{ViVuXZQTjyl zN7ojkD*EK+cch@$+P%(RHHbhm0_}9bNdPugAXj4w#RXT`!HC!+Q&34{69dijtOI1b z>j5X632nV~o^d3>#MO_zS|oF1CA5@5tLdw&w1_Mat-t2Z;ZIvi#*g8W*I7+Wt*;me zVn7AW&pJJG?g7Zx)D>D80u0=wdC^Zz+CGu3xvQXYrU+-sDK??XWof4flA< ztXtKu6JX3$rt}(Yh(XJqyIGksZBV@J=by7K!T9G>AAExglk0*FK0Z0my$Un_i_c%m(O(p3?M)@K-j`O zm&jgMsM`FjQ=8ZzG~_!xy)BO?1esZ9;b(1tIr4k76dXRWl__f%uJsJkm$0Y@ltn^+ zx0Q*3Y>Ws5Lq1`MKbRj)P&go@3-TH)n~U?>%fD~D(1?eRoPr1^(p>e;&@b(Qp8OD5 z>~oSG95YaQLe_o_;OykoyUYoO7-_+f3079QDkzjT0IIh0$5U3~qH?qP9T*H+{uk7` zc3J5W;S1=D;y!;7H9Z94U}_%!d~>uQzOBsm00^F_*J-zFpeV(1mHUAYUuq4=11j(| zUgvTxM3>m?V;@@S7nZu~6HTyGdG-Zt=R%1;E?3u5l^`;7kmOPViXy+7!wF#@N$M(v zYTuDV^C|vD?nUdqw=ZwEF@UDY62PgA;8ZX?J8~jiwB>$QO?FKdIMi-iW%yg#YaN%V zaD?x5C?y6R%vf87`QfcPkxAMNe5vo3pTSMuU&6X-?us8wSngU+fcr&!c91{*SY+)M z&&#zNl{C1yY;~I&=v@D-OZ(I`KrlW=-}(Ng_Nd3v>Zq~*`+{IaY(2Y*R)QQ*b|IPj zOZbN~YYYV3A{V*6ud?Y^W#D4b*#DlWH#brVYQ!fGaQp7Y z^g<(yYo&Zuoljn*ja@aPAw!FZ9bw_c|LJ{3eHS4g?VXLf1GZ(LB6IEf3wTZE0Y2{0 z%f<%WXcE)e*&UTKW78Lh(--B&YE3#U50V?iEz#^RaIu3!`eMp!yI;X!JP*5d2y}L~ zx7+W79OMBrqc;>E5+%*HTWq&_+Vb}0x5%=>DrVy(B`mH~-aVYAr6*cieA<6xDIgMJ z5|Vu2XErvNYvt>PQqT18gz)q^S7e#R$md?z3};2Dd;KSToyQB zpJwZHecG(SP@fqr(UA9hY1$8AK{hL7n|S1GEf4Q|0dUK81qCmFb(|(}Efy%Y6DcG7 z#Ul$E;zlp!svVRIfuo%cpm!+%O@ zJ7stMiw?Z}CpsW`MP64Kq|jsZ;mIO3zm<&Gz0fAcRz5;?MfQATNYC)Tn%ajSD}V8M zB-v6;_dwbej(bk{Z1b#9B`K(~k+Rs>OyC?HTs zRbP5ZVGo>Rl~|PMZ95rWH=B87?z24@27#@pO!2Z3&~KzID#ubPuX+}%(sW>dU)C4s zOjwDMeAqfKT4>HRp3TrniPPDOH2$QOu6mA`?Z>*A5vr?b!ik3=4 z4i}rDQ&H{9UKV2<;t(w~Snex-C}zv6owL4My#c;-Me>T^joZ+5UGsO5YV9{l@P?+Z z+Nw%y#ZW1o8 zgfw^-YJCoAXvQ;^(iMFdgr^Yv0`JiTNhin2NbM%&W~j%v=-Zl?I-iT9ef>pAh~HDBMg;?|S>hFQ3`Bgl!% zR)sz#YrQ$jU;y1K$%oSJzelzphQGN3VIxzSR0cA1#R}EIFHO>s*u#>Idf&(CMFkdV zPcF+mtqR%zt1FFE$2c84vVfS&^EHm_kDG5-fSyXw^URDaR0@L*g@Ay7e$X!qIehbN zmh7R!#i#x0Qq5vj`xw~35lrT*M1VvWQLtL!yoSJ@sP>GuK7$gz%p?X2n@>qbSulb{ z_bR8`pOzK}qZi?Xt_?<7UtW>dPnc`ZV4 z5Gp{3H-7~TD;Yzv1z3(uDLZ;CuGXKJW*S%AHg$Ttc+X@+j`?+=iXO~624elLZn1T0 zRw(ks1BhN+McuFE4h4BJ#vD?$``Kl`@}?43{);TQ^r{e=lhElnk z9)To%)b>){J9!vA`MjpRW1g#oah+x0R#!nM5x|>gL$KR<@ZIZCM;b{cMMZO#+{2JK z?=>3RfqvgIrJGQ?X4X`{aS{2of!#M`cJzwj3Lz62Jelz@J{)P^pP_BW!dn|CObzTt zj}zV>%8T5HAz<_ktg}KDoIoA?MdNFME<}A^DOSvkbh87js6LYQg`;de*6NTDr#!Ya zdLvc+nN)HR-2Q+IoxB>YXO^g|#pm{H$4cw>lf2Jbvk&qr26R88muX1YM$?s<-zKTQoiR}I8j5P0gTjanuG`m z>C4i!PmXc?IC)HzVD=TChD{LR#4doWAkU-Oz6OM2c`xL_(HO{qEI1&{A?0gL8DCu3Ex>EmhLgA#;mdoD>0{3w+QC9DZWek%0r?vo74CfRA7?oubux z*rq_&l-qX5-@Ip5MsY6$QPHg9y!)?6J`kj^qP(#8xo866J{UDPfJiceZ)!Ps!f@+t zI7i3{6Bkg78X6*J(bY1NCaEoVFf}{z#f=^}f|z5`$S`p*@+CkC7~p=BDl)sNpzi^U z$nX$ri`QMOY+aLVp=dJ*a&1OJHe(pst;S4}oT;`hS(>cmTO-91o~D{=Mx?S=xxa_; z0r{b%T$#W;&ojz9qyG@{dwP)A(z7?Li467CR2Nho#)vtTf3Ywh~amH>Il0 zfthH>@^y{L2v;Z#9jR>xc^2?w{XPk|Vx*-d!pMUV1;;a2lDY0dO@(6*Uzw*Jm)Fw~ z73r66zJgoqmiMTI5O1)S)8LnCgx9Weajux9$p16=F@6fQM3cGRIXpzI3P@|{^q)cc zI3J9#cP?3dtn0irBg;jFViH*uHNCqDdRJnL>78f8L^#6zckQn3gAASaW>(GMvrs~{D^9r_ky0XifoyHn zyIKRrVL{3^M4fe4EsBrfMi2dOpRZYdx2b-fXEV3c!1d3lOMntggd&9!-&gY89luaV zrHd8X`P?(t*?o9qX-@rqlz7+1f|}urHU27GSUD;Ea{JscZgYO4pBLYa@{7<@Drs9= znj6=|AOO(-+%$gp-TCqJUCz^>YOs`6-A96 z*K2;AuO2TX6VDx)sb(rT{t6)3_7v`&<3ncxopVbih$gVkf^#X#m*uM3{EKkQEymE$ zt6i_RW}Q+3^i<2I5vgx%5Jlgfw;4fQozEY3Q+<)~OMlzkUvPm*1pCHTkV}^`lVXnj z);{R{IPLtxXjIwC4;%Sf!J@qpn^VnQwodPO<;X=t8c&=_9F;dSKO)JtE0JD@to(1^ zUY>uyn$uf#MEHnqDG~oYBTF6@hGZ~-Sp?O=N|5%|!y7S*;>teqe;?D9aghkfN6>5e zbXAc}^+{t;l*7TzGDJTajRvS_Qy91j7q;)ARn_pi3AFen zyhk(h-9p_(Y(j~#D&#jy{7Zb>QJtD#VNb|8yMFpKXuD`u`dz! zHlC~L-2*AJHc}{x)CV*iZH1l}CRRBY_9`^NzkHShe!q;CG={wkQo~J3DU(2%IZ;%L zi=GWCKppVBhSt!5M4uK6zvZOyvY~rVAimlAUX~e(=~Z69ro?ygsf5~b+En+96XJ97KJTBt)ido%GvEfMhExM#5JU*7TAR^Q9+MB51M zLN(fO1&F1$RcR#wyfBfnyWI?nJWae90p~o)+UZUy5(OReNmxu9DH&Gy z=FR!@)eGwIsVL3%%e;b)JSv&b;p14L_!`tAKb<7E?k4=Mk{ICDcyNQqGAb6++p)~3 z#JM}w3Y@^cHfMnicFnkJ+bOFCEyJsg-+nIr{66=OIiek}1c_$uGa)h%LIO)}uL`!8 zf;G5CUNF`sf1T@VUiHat_7%?f>I_o*1oj9$!a8uU#Fd)_1|~At;^v_shk<|=lOaH6 zbR#Bz@8)zbZQ`nYD-{ZDZ_+wa#@KQo3AdM&lQ6=moDJ5*i6 zKGpu!l~ZuYc$#B>=B1sP)zu$af!+@bausjRLq<}mu(=suvrc7j> z493VsKXS|#da75(=uu3JdI#vY-qR3rX_X}+8DkDdGrEw;cHaM$7k}Pn29XW&;7CAz zSibpc#nWgzC>F>ZPV=T7K{gw_w3C847uR_bcSTtFS$o^E z2Io8FFKtHTx=3zo1)d!hG7P9X*X6MIsGqghk#ytQef!!HL}-y`x8>jJi`r^9FA>p< z^4mBvnK&9Muo*w*rsNlry9f4_6)hLG1=b-3cmvtAmJ*hIA{%@f(Ofw|Q$Jmt4`R@lMVAcx;nAuQop6j>>4_v1BV#JVwU zBW-sr@w^gV5cV!UO}ZB}73WA{m(mx0#a$HWueuB=2ToWGrNu$g8^u1=-fIfp|*N?8jNip6BIEC#t`~ zk1Wuz5-jk5J*fsp0Nun-`dpqcFm zs*2y`ZtQJk}LKDB2T7gf{5kuchrQ`7Qq*M7Q_Ke!tz)I&Q0NeJ!^w zpKd!fN4Tcx-x)wk+IM7GN>Wpq;W$(!=IShK6iUW|@in|IP57r#*ve`tR)u9oqy_;? zn9J*6guA3sIlb;+XsT8e!BJa|O7@=MKA*aLrNS9MfNN2PAcezogk=1(KxD_fW_33_%@ z#{W1d4*u$wkA1pRx>`|_Jebc2Yq*)*>x)}`8oYA z%I&L_D)MZ^3GM%+@ERYU>wG!8iG-1?X}RNrP}vy%xPGwALYpWf5$l)h&l1_wE?#li zmpuATHz%RZNdcyPMaN6YFa#4z(kj!cBzeVaJb;srMoRZ{Gw$80-S+D8BwvF{rOsua zkp@Ct%?-9?%+Np(q%pIT47s)|45t?%-GigwPjJJXbPT<3~F9&=1WdRi?XG(^52}d`v9gKDx zIHUEI***&Ll^3-}3NjXQME85isr(JUC=hGzIBE?)bRE7=8-3J3&Q}!z@$kM%de5wF z8{!{ao_XW7Qr&+p%K5ZXkn^yz_MGtmxA@%=P^(H<1EBntl1W1TXAln$4{!UnfTiJ4 z2I+Adw=w)6YRw09k9I1Ic-yz;BXx)F1Ghv&fA3bgE%*ZylezP=)eZOWDPyj9G<-!2 zS|c>EX_jP1`!gT^1CRyA_swJ=SQutokrLtY!1!E3C2dc9`Bze4#r28|mf@juWUgh( zzVZfs?d5nRip-&J_1<((_4h?q3b*K)rX>hJlz{HL_1(+t8`Iq%xYlp?GsF89X1lw~ zjr`hKeSd_xuvy-hHPL|l(X}~e&eVR@Y6ZIXxQlft#b7m_H1ejA(539BTmm+3Pj;iGk#$kOEU>mZBE6mc`{se`OgwM{3 zaJ{OghY!OjBG479hGC_A>r?D~PBZLuDBfB&l#xQJu4JJuTtgzyCQsA%BRu65H5LB} za(X-M6OG9;b57nEW_SuZJmhX&DvB*-;(9^r5Y{-@1ZkWGZ9h1*tgv?{7N*Q#A0DTD zVGLgpYTVK8#<5+0b;<2=@hOc_EqVv1afZlLSi-isz7;?pafZ^-c3k~9SJcZRJ(K0a zwo~9?J22~Fm(px!7*F|JS(V|Vdy09lqRQ{`lm9$T0OvvenK10I@uZr+B+x^uX1*&m zTB>aI2v_(XEh{Tj^9&ip^(6kw(c~w_X}MnS+=dHPt2Kh%ak{GTzxgh<$v! z*^U#vb6>!Yu|2tLF0Ev>SVF99rElxKvZ;vboDi&$OttZB62bKHKLu5vM#8?xMTx}d zwXbiB$xlWb{*!CyOe0qQF-v;dn6j-ss@-ub_9D%K&I_=#46g5$v#q4P3d?po6Fb)mwC8NnhtI1DgJ&rEg9x z-$(}lFIuzVB3GJE#Rr2=Iq1;kH#89cp_9%Z1oRneKvLsnI5Xe*{&EnSlh5i@M4Yc;rGulL;FIFMXcps=l=1CNIVhoi1K75`^Z- zl?h$5J`xVpHR6A)w@$Sw_@3N{=y>i49e?kbKTrAO4hFs9K^wQ4VKqs;LhO_b6$u+Z z&gF?_Wah z##q0Uwz0a{w81XTVtyfY9y&D}hZ38~e%9pvh;S0iWI4$H((5tC(YN*5wa^-|sbi0q z`id+pb9sZ*f8nKx5{~W8W7`o_iLmU~#*(-$lK;AE|LJF-%iq(l%VAz)s;j_MT9Gzz zzW-x63l5QDlcFV5U@)w#D8u6p&uR$Z3KrwPqKs{se}l^r^&V)c!aWtzN7`&0isXZh zrOD_qWZ1E#U={S12CnDq{R01(Xa*SjR)66ST@9_`&E)r)ho2{yn;V^Za2|>14siIF z#~B0|-2IAqq=>?=POn?>x#JZ9pYu&{R5DcA=;}P0#CsQg8&kVpS^un?hIAChQQ)R5 z>MUMi#}3k1mzCQK@TqgJXGrQNhJem(8t(e3q?_uZ5Jb`zs>{S+>0vwdv{0`2&E`|VKGBWr*8J6VTo)6Zom z=HUEp>?pjWQ71X|-H<2iUt&5edO`*TTEeYLx9vO~t8um~0;VdOa$C&iJd}@hC$&2E znuG$oi)FuG<^L;s-`@24XZ{yEMOhAmAkizVg+0;5&ADysU){CpuYPszTw=>;lWJKsb|Q^h}8yhhS7 z3s6hJqNh)z3l-qD3Z?vi z8`^eW-JgP>JI~PwlkQN;>AkEvr5t@s7A)*LS=|?Fj+7XmJ^}ysnv~C3NQ1# zV3rk;=sh8*nirG&hc)!`}8WwIgXDN&VDDW(J?!!;3bN?@G zAx;d#OE083{c>hX7=z!??djoGBshwZ_p6)^>vDA-}kov66~KrXzZ<2%aV!T4^QoBRD^^qnawZ$RQeN@6M=#f zEJK@!J!mG+utmZx7;k_Jb8}p%ON>1Q>CZoetoV@J&EXQXiYw?)fCaRS&kII{|A^Mhet9{pG6xJH$ zO71>+Q*yI;U{1Tj4Gvyx)SgjRy$X7ZFPBx%cHM05H_jVdtvi8fA!~QDO-%RH&iCu+ z?|$$L{y4y!&j0X1u5>P%V$4yV#)k4>vah%sw#!3UFvN*HnvU~BLG^NHDo32qNG7f5 z6KyysTup11xc1mf)X01sTX__Nvv{IJh$h*0qLpf!0`<^4Hfk5w|mfLFWtrRno$0_20?bY9Qw|NQ13f8hOR&FAc@ z-dS#GiqIDL)jJ+%U)nR2$_W>)K9=a}eXfk*`MzT--zT5N>;cBXtm7jICakD;>9dHt zGB$DIxr?=@;+?_12x6ZvA1uIHQY3-X*Jy-~_@r>1!%?g=KoSWIoR8Z%Z`_bN0bjUF zdC*4cA0LFQ%PQJOxw1qL4d1EbP>!jYMP|?NMAzk?{r6h)6qheX#Of^HY2uT}$N)b+ zVR=CINR@m3X9BP!EK})qry$B9RJ{wn^qL2(mkI%MtCIm5PE>E$P(EM;@>v8(#P?L& zKg`wJTUuJin({w{@N%>unF=Wgdn%bRZSY1hT~RW@++ z(=}fTHwk*ouKxrEcYh}C{j=PMUUnmEBW~D-!Sqlsa`h@YEEy&eMjq$vqOuv**hHP@ zyD`gec!yF<<@8MOoFZVf!gm}GpQyE#VNF~9~f+1&i(_GU1JcKSi(F z!qA`g0-SodU=OAHJ*8Pd^n0+RDHJK|hji5?kOWnPd}`HtvPW{Pl?L5e7-dc9;lk-P z7~Y=^GK#xe)cXrN4*~84WT+pdZ85J25TJE256#p+bx(bCG!GhSm&-21x}Vao*&;)0 z_-~Rn+xB~~8h7k~QzlB2yFlC@68R#^7A?%{q_g9_PP|S(3AShCqNk1rh>e>ERLPU+ z(i2cl*G5@F{{_=zhddQYr;oALCdKnx44m!MECC@@)&7rMl+B^0v$S1zUFhPV`xf;V z4lT|&dsx2&wsh6X0zaUtxG}yhcwxzpt&2_u!2+X@$OMkL>E|Sd zvG4@7791(p0(1Q$)U@+zA98LPwGGQIm!`i}f7i)o%Lsc?Pp~{~#G++pXQnqf8!q0e zHPYq4M%Z-ICzHOWBMSLu+9fVSCSUb=K96Sd#24e*CI$EIi_v9ibF>xb>x>s3`&rZn zSusV@jD+O#?CEHA`)VYVgj=4Cb&*(If~vCzGf{r0D=(kq-*Hn_@U;v3-nnM)MlEaZ zw*4zD&IbF(`5v0SAfH8&Wb4KIXI7<`N)1}vGM@6y0c;-tG{4_nV_d=r0AOnjUv@N| ztBtaYpy%VdSYR+DfL7<=GO~N)0`W+$SFKwY=Ca^5z7c2T4U1)^s&}25?wJANnn z!B7&s!5V4s5cMfJblB%;k>BFYkh6%K_X(4+EuRrJRXvY+s?Mo#7~>jD>;kKok-egM z&7}3rY47cV_uc>gCsV&N>remiH0HA+Fx}9ji4z*x!Wh04rHx81*>D|G=DXh>9$=1H z=RBbf$kInpITa?yae(1A{Vt^iWp=G0htF*v#OtJdgslw&Yo6-Jbd zK~SJSMm6xLvc8B!r(VS3H}jf0eyhnr;7)7q*o#(il6GJ@&O1`w^2-dORh%|LsvAd1 zUvvD<^zB$JrFE(DYtTYZJW{0S8eA52(;yQh0Y8(dPq^5>_&Zevy(dDZHuxKN8KV~Ok~PkixZE$h)Pv!ayo5<(Pp4vDNiXvsim38hlL%dnV* z2Vf}IODWVznmDnyQ}l-E7E?5GRh0gVF}|P7vw$uJpHv7J{xYQ=sx4mCc^xLI zglXJv(D$iUSC;eX)+5Y`EkfHIz6vDlf&P8$y!`l4&8NU%sZiIfqZ^M&cZQ*i(Hj*R z3^>Xbj+3Jtkn5xKb;Pn~l-mW0DTjxyR- za8wI)Ms%e%y~f4F^S&v&4g1kVs|WEFM5wL*e4NnHneWh3=uoy6`-HyFT=rU|%M$_Y z9M-jt)fnyn&Ra_7wKM?iLclpsmoaZw)4X$*$ee}JpcRBqhhtCdRO>37gpKTY^hRZ( zW@Ydn-HUlOjaC@H1SV|?pbh3iZ{A!)r^b0V2tU3s(6wKCzBBa*hM~$35Tq3n$^7J* zY5%J@S(c>o0-brHOi>h_<5ZxFhccP=C|mnPyEea;>Tp|1fEBmm_xefUA)g`BOhLbo zvU8W1mKQ`TXOezwwyXrDtvwag#TuE=R5g`#?Vg)_xcdLFZai1a6b+z?wu92bH&&>t zGh!Dar{n4E;0j!ON}7l0@|h<`I()He{!6)F7rf=*m!5njaGrM{Z)(4R`(lMoyLb79h-{&5Hl`!aQU*p*qBgtnsA~M=0OEX(eRwR^xKsb8I;frYWvCuCl+dGvG{*@9@RSk`dCASQGba3!zD- zSsW4vQ5%R{gGrYB`|u*kJt$|ws?01b#G0|&G&_Y`?#K-j5<4hNyM@u^@9&;V8~tX) zi!wN7sfcC;-j)|Xzm!&Ae0(1C%5@#>!a2BPRn%xXFu;Tk16p0+d>&xo2W#0Q{{SiR z9-~jB9sff5s=CrsE6>l?)j%n_5RE@oZ2UGcjlFVX>353BQ?_rYwR09#td;8#TCB)A zM{?(whaNh+a{WLU%NRKYK5e8Q@5SG)>f|a7>H#Pah-Q<<)Vg;*P1k^{3=-hC1hQgiw zQE6YjgjYBy8vg+HKqYc!vK3=Q^6{qldo%~jPu17ElN%s*yR2I5Y$6sfHgw;pAbq8< z#~O|D^k)py+3&ihn%@PhgwYpYUspJalVI^1(r~Mo&;=sidAqTw{Yfg+@y6UF6Pv+i zwC70B=Y5ga8uxk_j!s4UqMa?t{vdRIzJh!-F!sGQu(!4_qzfd3v5=(%YYT!m5UPnl zSAHZ0gw6prZXx)%J@9ol8~&()X1_U}4~T^PqMrL~FK+rXer1KKGf$v2@77?PYipeC zpgjOQ=zJKXpCd$bKoo6vH6G*Z)p{EPl&?0TsWG2`+9cu^$-H~p|O(Rn~teM=S( z1UEpDgkYThvYm5sy8wI_pU+3$CNfyxFa4LF@LwFY^qcBOsmk8&Y7hlo=1Iik68SuRCU&3!TQ~iO@n~ z%XO^g>^p!{J-+?GXi66r)$*Tdr4+!T z8)vJ=Twk5Ije-HK#*etlG>z7Kqu>ow1EnO+--zKlqq5DDbLU|Ls8vCl=aKQyGFOv8 zid{%}B^OQ|9|49gS&F0n+FrZM*HV@fny$NVsm^=s%nVX=!6mvmeC!;|oQo_BeB47m zPTPa$a!rtfG?E{>RFWi_wjxeT_dBMN#A%h4ZYV^?D9zbZ=96sEQ%ELG2#HPYNG){r zF2UzfTXGUbPd8)7-wA(Ae;~V)&n9jnfOpe=3bKH6Ap#Y^NE--PN@704^gz0h-iv%4M~KM4iC!}9=}jy-k48V67+2Q-Wr3;I~x^J*zmVG1fqK`y@*i) zo%9TdDALRoWt$j9c~Tj9zgEevfEjF+>avi*{D`tqMz9pW$!bj{1TC_gF24KKU$T~< zunMfu?gop4q>>y*;ySzZKBkRTtbWbeFHN>Fj?vs>x+{Z>c(xegAbg z%|yEKB3<5|J6w?v5PO{$3M4}@V{-{iocP*-207HtT$JKFyC-+{Oz8BgXGnIwRjTCz zb;I~9L{(?I(U{~tPQt|3Ut4h5?5ctp0%@`Zg70X3qGcSkzQ_ zwz;(~iEP84!@7-bWQ{i#N5=--NABmpKRMNFl>t!@2}z1EiQJv=nJv{qWWV}YJpwR2 zlVUVz(Q57jivTRScjO@&n2FP{bbr!l6=Solo)r1JS92)w%h!4B2GgLVRy+UW{k&wz zh4ULff;f4)9e5;$$Uy^Fh;ecHjPK(i=Z3JEKPt%Y(qwh=o1_5>G+jpCPd(M=mJ2(V zJC76z^8S3zdcot90vLOe$E?snt4i~>>X(ycHFAO; ze~3(0MyI|=`gPb681KcF%Xh}0a_Aq`ltOC!E+O7J_<3|VG2ih#Gc2r?QA$Zd>u_&I znzdGzHjvptpEs_MEk3vNZ{kVw9;+5zB(JdT;Wz^)CS=Ac)+Y$|nTP1DWG9-o+vO{K$k@ggv|O-|Za3f)2leiFIaR?|!`0{zd8E)L&2fA3rGHlBRqZe9_MaQh z*gMJUZSy8hS5{`E4-Ld*Z2e)3gfS-8Qlhm6iJt;fborK@DT|~rVW18a?I9VcEu^MN zEXB9E9{%X*08d?0oyl9H5IG|&xfYE_}T&-U^DMagIq?mNgQrL5`NK( z_j#3eWRfZPf1Q6AAdP(MEm!63D84gnm)JH z)S1U%tHWv5ol^>0JH7hX@|tQM<|UqIFB1#iV!3b>jD>9G-&}Gysz0M*6DSHMDHWfJ z-`8aIv&6x0Gc{^9<8vIcL$aBQ!a3@Pmb8Y@jZ+e5=46N9Vo5j2mgGP{%LX;TAD+dI^#8U(uwC)J0 zf3o#@7}9Gj)`mx_bydht=|vIc9X(zXPTNMajt{>a*#kwsfjXq|VW5A3f8gs%){;xZ zmXvi}7K@I_%&Tu=y+PRui!eqV1O#N&y2qPi^OeA25iTFF6qs z_o&Rq7r6o+EQ-mH%E9K^>RUe#qqb;sJ3?v^Cdg796i?W$_ihTwGZ14(cu_~04Qz5V z?^Rp(poYB-n{gj#|KV}m*X|;=sntCdjbvM;Kgs69+j)Awd>dtrq0M ztQ?#6x8Q@9z4-!@chl5QN}DRS%&=kdpGE!wF>-A02@(R?rZ+#TFwXP7^D<6FYaJ13 z{YgOi!E5BR9dF&@dr#d|ynE9kc*gsLWX773kPRCVkHOn&Vk+%69!{)EW2G<74vz^7 z*;)dOm8>qf5KvjjiZX}X{Y?fIJTQN$x*g!$YfVkrNrQ3o6*wN z>6Uo`${ue}XTA|dYZnFU1+Fwtzma0Nw1r5Jg+|wt{toW)KND!vtpGZ45f8u%PbrYDsKRZi>oXN&lLI1P#I=Nsl-1vcxgCG=d=Hzv7zFAoQ+ zZ=Pxh0UA53z(FfXhlGN}&Mrb+TQN_YtD%;c7 zHuRBTM@vJe$hG3|4AuQTGMCr>9v)Rc=32SSOFcbP^uR70$~O4fy)1*1u0v`3e}9+W z!N!5%o1g8s`v^f$@89H?@>(XC{GEghOK8xt`uRKG&(>2m;YBl&4dZsQRloh*>ce!- z_cJ9#=Z`kJL%Ea`YQan+&7klRNpyuScm|0c`9~Zzu2Gb%+hE)Ep*2Pt?OpyEH=fGF z*nS7mfju5nRWmko z488u;OnTK1r8+7q1)-=my-HX)Se1vkOw+z8P}Szd*Q?c@!~+^92hBfuTH*mc5Jm$M z53(paO%p;))7VaCt-we&Y#FwCzr(vB*3gSO*ut}RcXzv*@F6xD89C7#Y%;hsNrjQg zI+&Q1#C!V7I>(Tt4A3eDm!elgbey)^r4}^N%ADiiYca~pszR%IULSsAON)2F2GxQQ z<%dqP3Fby%I+2YwS#5 zHML^L@5h~&N?UxPx49y=p%}KS!e(`}!Ns~smQNe{>J5EkEM%3aEHhNyv1^hA2O!7L z{0{FvhUL*19fJfs0cPwNvD7*jwBSjfUq)yM!4hM^Dw0b*5OI&)69 zd79^}!Zcs2uhA^?tVzLmF*uE0aq}Bjh0Y`di9iSuNP1N=V~KhSpB_fvPprlR*QhH3 z^t?iQ4!MpjxO>`{l78L{WPzP;fUclc9r{vhh;{u3oeF4k8fk}TAfdNYPL`jzl zG0glX!Mho$%*zAo&#&%YZ7ea5xK>;CM*z;UEX#60*g0|pxLQWcvW%CQS2*X2X}*>a ziV~`jfFlO`X5mbuS3M|(P?xO}lBn%=Ap|0ES?bx3rgha#qeb*-jCzBT*@NK1k zC4&q?2qA(X2uKLh)7#k4guJl}VW2&V{bJDbB(}CHMcY0@v^zSO&2N1Tmphs|5c*`J z`dCE~S>65s5kB|1&y97&x1baMUI3OU39nwgI{bQ_Fl{SM)Af1{fJcrTk(ecfn2cHS zq{=t0)SDW)7P&sU|Jdc}Rd@UfjkKr`fsj+ytznZ4z!Ir=DmZQg)keb>nrnsVUqJMX zG8|AcjeRMtW{q^7rzFRH>t1O9Vf@xM&8c@eU@;HAhHA}&yH|JHIa1GH)oQ(7uh;7+iY8;0^E>SQ(i0tGaC`L1x~&-W zDhPrg5J4bFP}OQsW1iH7GKj?;7sdWk_Mx!V;8P}+0a*Q!z!@a#E1B+DKx}FLXU=3b z4frj<*6?rg?qFEh`&Q^)F2t!n(6h9UB8)-+xOM9`031Df^ytx}y3b#za`2;0WDo@F z_4?ScW5}nG4C0Q^krKNgg>xcu{o#40TOb!1g{~s`