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 0000000..39d7511 Binary files /dev/null and b/docs/cylinder.png differ diff --git a/src/main/scala/Cone.scala b/src/main/scala/Cone.scala new file mode 100644 index 0000000..234d61c --- /dev/null +++ b/src/main/scala/Cone.scala @@ -0,0 +1,96 @@ +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 com.samuelcantrell.raytracer.quadric.QuadricShape +import java.util.UUID + +case class Cone( + 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² + 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) + } + + 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 { + None + } + } + + protected def capRadius(y: Double): Double = math.abs(y) + + 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) + } + + protected def isOnCap(localPoint: tuple.Tuple, y: Double): Boolean = { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + dist < math.abs(y) && math.abs(localPoint.y - y) < equality.EPSILON + } + + 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/main/scala/Cylinder.scala b/src/main/scala/Cylinder.scala new file mode 100644 index 0000000..dcfa5bd --- /dev/null +++ b/src/main/scala/Cylinder.scala @@ -0,0 +1,86 @@ +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 com.samuelcantrell.raytracer.equality +import com.samuelcantrell.raytracer.quadric.QuadricShape +import java.util.UUID + +case class Cylinder( + 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 + 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) + } + + 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 + } + + protected def capRadius(y: Double): Double = 1.0 + + protected def surfaceNormal(localPoint: tuple.Tuple): tuple.Tuple = { + tuple.makeVector(localPoint.x, 0, localPoint.z) + } + + protected def isOnCap(localPoint: tuple.Tuple, y: Double): Boolean = { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + dist < 1 && math.abs(localPoint.y - y) < equality.EPSILON + } + + 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/main/scala/Main.scala b/src/main/scala/Main.scala index 9fdb98a..3b96168 100644 --- a/src/main/scala/Main.scala +++ b/src/main/scala/Main.scala @@ -1,6 +1,8 @@ import com.samuelcantrell.raytracer.sphere import com.samuelcantrell.raytracer.plane import com.samuelcantrell.raytracer.cube +import com.samuelcantrell.raytracer.cylinder +import com.samuelcantrell.raytracer.cone import com.samuelcantrell.raytracer.transformation import com.samuelcantrell.raytracer.material import com.samuelcantrell.raytracer.color @@ -92,6 +94,36 @@ import com.samuelcantrell.raytracer.pattern ) val cubeWithMaterial = cube.setMaterial(cubeTransformed, cubeMaterial) + // Create a cylinder + val cylinderShape = cylinder.cylinder().copy(minimum = 0, maximum = 2, closed = true) + val cylinderTransform = transformation.translation(-2, 0, 1) * + transformation.scaling(0.5, 1, 0.5) + val cylinderTransformed = cylinder.setTransform(cylinderShape, cylinderTransform) + val cylinderMaterial = material + .material() + .copy( + materialColor = color.Color(0.2, 0.6, 0.9), + diffuse = 0.8, + specular = 0.3, + reflective = 0.1 + ) + val cylinderWithMaterial = cylinder.setMaterial(cylinderTransformed, cylinderMaterial) + + // Create a cone + val coneShape = cone.cone().copy(minimum = -1, maximum = 0, closed = true) + val coneTransform = transformation.translation(0.5, 1, -2) * + transformation.scaling(0.8, 1, 0.8) + val coneTransformed = cone.setTransform(coneShape, coneTransform) + val coneMaterial = material + .material() + .copy( + materialColor = color.Color(0.9, 0.4, 0.1), + diffuse = 0.7, + specular = 0.4, + reflective = 0.05 + ) + val coneWithMaterial = cone.setMaterial(coneTransformed, coneMaterial) + // Create the world with all objects val lightSource = light.pointLight( tuple.makePoint(-10, 10, -10), @@ -104,7 +136,9 @@ import com.samuelcantrell.raytracer.pattern middleWithMaterial, rightWithMaterial, leftWithMaterial, - cubeWithMaterial + cubeWithMaterial, + cylinderWithMaterial, + coneWithMaterial ), lightSource = Some(lightSource) ) 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 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 diff --git a/src/test/scala/CylinderSuite.scala b/src/test/scala/CylinderSuite.scala new file mode 100644 index 0000000..d10f157 --- /dev/null +++ b/src/test/scala/CylinderSuite.scala @@ -0,0 +1,259 @@ +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("The default minimum and maximum for a cylinder") { + val cyl = cylinder() + + assertEquals(cyl.minimum, Double.NegativeInfinity) + 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)) + 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) + } + + 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) + } + + 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) + } + + 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) + } +}