diff --git a/output/ch14_world.png b/output/ch14_world.png new file mode 100644 index 0000000..5f06e7e Binary files /dev/null and b/output/ch14_world.png differ diff --git a/src/main/kotlin/apps/shared.kt b/src/main/kotlin/apps/ch01_02_shared.kt similarity index 100% rename from src/main/kotlin/apps/shared.kt rename to src/main/kotlin/apps/ch01_02_shared.kt diff --git a/src/main/kotlin/apps/ch07_world.kt b/src/main/kotlin/apps/ch07_world.kt index 0ea515b..9682c75 100644 --- a/src/main/kotlin/apps/ch07_world.kt +++ b/src/main/kotlin/apps/ch07_world.kt @@ -12,6 +12,7 @@ import scene.World import shapes.Sphere import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val multipleLights = false @@ -87,6 +88,9 @@ fun main() { Camera(1000, 500, PI / 3, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch07_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch07_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch08_shadowpuppet.kt b/src/main/kotlin/apps/ch08_shadowpuppet.kt index 49b48df..659fb95 100644 --- a/src/main/kotlin/apps/ch08_shadowpuppet.kt +++ b/src/main/kotlin/apps/ch08_shadowpuppet.kt @@ -12,6 +12,7 @@ import scene.World import shapes.Sphere import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val wrist = run { @@ -77,6 +78,9 @@ fun main() { Camera(1200, 600, PI/6, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch08_shadowpuppet.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch08_shadowpuppet.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch09_world.kt b/src/main/kotlin/apps/ch09_world.kt index 9e9889f..40e66ee 100644 --- a/src/main/kotlin/apps/ch09_world.kt +++ b/src/main/kotlin/apps/ch09_world.kt @@ -13,6 +13,7 @@ import shapes.Plane import shapes.Sphere import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val multipleLights = false @@ -88,6 +89,9 @@ fun main() { Camera(1000, 500, PI / 3, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch09_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch09_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch10_world.kt b/src/main/kotlin/apps/ch10_world.kt index 8995d5d..9b8c362 100644 --- a/src/main/kotlin/apps/ch10_world.kt +++ b/src/main/kotlin/apps/ch10_world.kt @@ -14,6 +14,7 @@ import shapes.Plane import shapes.Sphere import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val world = run { @@ -95,6 +96,9 @@ fun main() { Camera(2500, 1250, PI /3, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch10_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch10_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch11_world.kt b/src/main/kotlin/apps/ch11_world.kt index fa284e4..ac7cef1 100644 --- a/src/main/kotlin/apps/ch11_world.kt +++ b/src/main/kotlin/apps/ch11_world.kt @@ -16,6 +16,7 @@ import shapes.Plane import shapes.Sphere import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val wallMaterial = run { @@ -113,6 +114,9 @@ fun main() { Camera(2400, 1200, 1.152, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch11_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch11_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch12_world.kt b/src/main/kotlin/apps/ch12_world.kt index 67b7e66..898995e 100644 --- a/src/main/kotlin/apps/ch12_world.kt +++ b/src/main/kotlin/apps/ch12_world.kt @@ -14,6 +14,7 @@ import scene.Camera import scene.World import shapes.Cube import java.io.File +import kotlin.system.measureTimeMillis fun main() { val floorCeiling = run { @@ -152,6 +153,10 @@ fun main() { Camera(2400, 1200, 0.7805, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch12_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch12_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } + diff --git a/src/main/kotlin/apps/ch13_world.kt b/src/main/kotlin/apps/ch13_world.kt index ba3ba00..b902bd7 100644 --- a/src/main/kotlin/apps/ch13_world.kt +++ b/src/main/kotlin/apps/ch13_world.kt @@ -15,6 +15,7 @@ import shapes.Cylinder import shapes.Plane import java.io.File import kotlin.math.PI +import kotlin.system.measureTimeMillis fun main() { val flooring = run { @@ -108,6 +109,9 @@ fun main() { Camera(2400, 1200, PI / 10, t) } - val canvas = camera.render(world) - canvas.toPPMFile(File("output/ch13_world.ppm")) + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch13_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") } diff --git a/src/main/kotlin/apps/ch14_hexagon.kt b/src/main/kotlin/apps/ch14_hexagon.kt new file mode 100644 index 0000000..2e2ab5a --- /dev/null +++ b/src/main/kotlin/apps/ch14_hexagon.kt @@ -0,0 +1,40 @@ +package apps + +// By Sebastian Raaphorst, 2023. + +import light.PointLight +import math.Matrix +import math.Tuple +import scene.Camera +import scene.World +import shapes.Group +import java.io.File +import kotlin.math.PI +import kotlin.system.measureTimeMillis + +fun main() { + val hexagon = run { + val sides = (0 until 6).map { Matrix.rotateY(it * PI / 3) }.map { + side.withTransformation(it) + } + Group(Matrix.rotateX(-0.4363) * Matrix.rotateY(-PI / 18), children = sides) + } + + val world = run { + val light = PointLight(Tuple.point(0, 10, -5)) + World(listOf(hexagon), light) + } + + val camera = run { + val from = Tuple.point(0, 0, -5) + val to = Tuple.PZERO + val t = from.viewTransformationFrom(to, Tuple.VY) + Camera(1200, 600, 1, t) + } + + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch14_hexagon.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") +} \ No newline at end of file diff --git a/src/main/kotlin/apps/ch14_shared.kt b/src/main/kotlin/apps/ch14_shared.kt new file mode 100644 index 0000000..38fba0c --- /dev/null +++ b/src/main/kotlin/apps/ch14_shared.kt @@ -0,0 +1,20 @@ +package apps + +// By Sebastian Raaphorst, 2023. + +import math.Matrix +import shapes.Cylinder +import shapes.Group +import shapes.Sphere +import kotlin.math.PI + +// The shared side of a hexagon. +val side = run { + val corner = Sphere(Matrix.translate(0, 0, -1) * Matrix.scale(0.25, 0.25, 0.25)) + val edge = Cylinder( + 0, 1, false, + Matrix.translate(0, 0, -1) * Matrix.rotateY(-PI / 6) + * Matrix.rotateZ(-PI / 2) * Matrix.scale(0.25, 1, 0.25) + ) + Group(children = listOf(corner, edge)) +} \ No newline at end of file diff --git a/src/main/kotlin/apps/ch14_world.kt b/src/main/kotlin/apps/ch14_world.kt new file mode 100644 index 0000000..3becd2e --- /dev/null +++ b/src/main/kotlin/apps/ch14_world.kt @@ -0,0 +1,83 @@ +package apps + +// By Sebastian Raaphorst, 2023. +// From https://forum.raytracerchallenge.com/thread/13/groups-scene-description + +import light.PointLight +import material.Material +import math.Color +import math.Matrix +import math.Tuple +import scene.Camera +import scene.World +import shapes.* +import java.io.File +import kotlin.math.PI +import kotlin.system.measureTimeMillis + +fun main() { + val transforms = listOf(0, PI / 3, 2 * PI / 3, PI, 4 * PI / 3, 5 * PI / 3) + .map(Matrix::rotateY) + + val cap = run { + val trans = Matrix.rotateX(-PI / 4 ) * Matrix.scale(0.24606, 1.37002, 0.24606) + val cones = transforms.map { + Cone(-1, 0, false, it * trans) + } + Group(children = cones) + } + + val wacky = run { + val legs = transforms.map(side::withTransformation) + val cap1 = cap.withTransformation(Matrix.translate(0, 1, 0)) + val cap2 = cap.withTransformation(Matrix.rotateX(PI) * Matrix.translate(0, 1, 0)) + Group(children = legs + listOf(cap1, cap2)) + } + + val backdrop = run { + val t = Matrix.translate(0, 0, 100) * Matrix.rotateX(PI / 2) + val m = Material(Color.WHITE, ambient = 1, diffuse = 0, specular = 0) + Plane(t, m) + } + + val wacky1 = run { + val t = Matrix.translate(-2.8, 0, 0) * Matrix.rotateX(0.4363) * Matrix.rotateY(PI / 18) + val m = Material(Color(0.9, 0.2, 0.4), ambient = 0.2, diffuse = 0.8, specular = 0.7, shininess = 20) + wacky.withTransformation(t).withMaterial(m) + } + + val wacky2 = run { + val t = Matrix.rotateY(PI / 18) + val m = Material(Color(0.2, 0.9, 0.6), ambient = 0.2, diffuse = 0.8, specular = 0.7, shininess = 20) + wacky.withTransformation(t).withMaterial(m) + } + + val wacky3 = run { + val t = Matrix.translate(2.8, 0, 0) * Matrix.rotateX(-0.4363) * Matrix.rotateY(-PI / 18) + val m = Material(Color(0.2, 0.3, 1), ambient = 0.2, diffuse = 0.8, specular = 0.7, shininess = 20) + wacky.withTransformation(t).withMaterial(m) + } + + val world = run { + val color = Color(0.25, 0.25, 0.25) + val light1 = PointLight(Tuple.point(10_000, 10_000, -10_000), color) + val light2 = PointLight(Tuple.point(-10_000, 10_000, -10_000), color) + val light3 = PointLight(Tuple.point(10_000, -10_000, -10_000), color) + val light4 = PointLight(Tuple.point(-10_000, -10_000, -10_000), color) + World(listOf(backdrop, wacky1, wacky2, wacky3), listOf(light1, light2, light3, light4)) + } + + val camera = run { + val from = Tuple.point(0, 0, -9) + val to = Tuple.PZERO + val t = from.viewTransformationFrom(to, Tuple.VY) + Camera(1200, 400, 0.9, t) + } + + val elapsed = measureTimeMillis { + val canvas = camera.render(world) + canvas.toPPMFile(File("output/ch14_world.ppm")) + } + println("Time elapsed: ${elapsed / 1000.0} s") +} + diff --git a/src/main/kotlin/material/material.kt b/src/main/kotlin/material/Material.kt similarity index 53% rename from src/main/kotlin/material/material.kt rename to src/main/kotlin/material/Material.kt index e19391c..8d24fc6 100644 --- a/src/main/kotlin/material/material.kt +++ b/src/main/kotlin/material/Material.kt @@ -5,31 +5,39 @@ package material import light.Light import math.Color import math.Tuple -import math.almostEquals import pattern.Pattern import pattern.SolidPattern import shapes.Shape import kotlin.math.pow -data class Material(val pattern: Pattern = SolidPattern(Color.WHITE), - val ambient: Double = DEFAULT_AMBIENT, - val diffuse: Double = DEFAULT_DIFFUSE, - val specular: Double = DEFAULT_SPECULAR, - val shininess: Double = DEFAULT_SHININESS, - val reflectivity: Double = DEFAULT_REFLECTIVITY, - val transparency: Double = DEFAULT_TRANSPARENCY, - val refractiveIndex: Double = DEFAULT_REFRACTIVE_INDEX) { +class Material(val pattern: Pattern = SolidPattern(Color.WHITE), + ambient: Number = DEFAULT_AMBIENT, + diffuse: Number = DEFAULT_DIFFUSE, + specular: Number = DEFAULT_SPECULAR, + shininess: Number = DEFAULT_SHININESS, + reflectivity: Number = DEFAULT_REFLECTIVITY, + transparency: Number = DEFAULT_TRANSPARENCY, + refractiveIndex: Number = DEFAULT_REFRACTIVE_INDEX) { + + val ambient = ambient.toDouble() + val diffuse = diffuse.toDouble() + val specular = specular.toDouble() + val shininess = shininess.toDouble() + val reflectivity = reflectivity.toDouble() + val transparency = transparency.toDouble() + val refractiveIndex = refractiveIndex.toDouble() // Convenience constructor to create a material with a solid pattern. constructor(color: Color, - ambient: Double = DEFAULT_AMBIENT, - diffuse: Double = DEFAULT_DIFFUSE, - specular: Double = DEFAULT_SPECULAR, - shininess: Double = DEFAULT_SHININESS, - reflectivity: Double = DEFAULT_REFLECTIVITY, - transparency: Double = DEFAULT_TRANSPARENCY, - refractiveIndex: Double = DEFAULT_REFRACTIVE_INDEX): - this(SolidPattern(color), ambient, diffuse, specular, shininess, reflectivity, transparency, refractiveIndex) + ambient: Number = DEFAULT_AMBIENT, + diffuse: Number = DEFAULT_DIFFUSE, + specular: Number = DEFAULT_SPECULAR, + shininess: Number = DEFAULT_SHININESS, + reflectivity: Number = DEFAULT_REFLECTIVITY, + transparency: Number = DEFAULT_TRANSPARENCY, + refractiveIndex: Number = DEFAULT_REFRACTIVE_INDEX): + this(SolidPattern(color), + ambient, diffuse, specular, shininess, reflectivity, transparency, refractiveIndex) internal fun lighting(shape: Shape, light: Light, @@ -62,23 +70,6 @@ data class Material(val pattern: Pattern = SolidPattern(Color.WHITE), return ambient + diffuse + specular } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Material) return false - - if (pattern != other.pattern) return false - if (!almostEquals(ambient, other.ambient)) return false - if (!almostEquals(diffuse, other.diffuse)) return false - if (!almostEquals(specular, other.specular)) return false - if (!almostEquals(shininess, other.shininess)) return false - - return true - } - - override fun hashCode(): Int = - 31 * (31 * (31 * (31 * pattern.hashCode() + ambient.hashCode()) + - diffuse.hashCode()) + specular.hashCode()) + shininess.hashCode() - companion object { const val DEFAULT_AMBIENT = 0.1 const val DEFAULT_DIFFUSE = 0.9 diff --git a/src/main/kotlin/math/BoundingBox.kt b/src/main/kotlin/math/BoundingBox.kt new file mode 100644 index 0000000..f198f8f --- /dev/null +++ b/src/main/kotlin/math/BoundingBox.kt @@ -0,0 +1,111 @@ +package math + +// By Sebastian Raaphorst, 2023. + +import kotlin.math.max +import kotlin.math.min + +// Unless otherwise stated, a bounding box is empty, as indicated by setting the +// 1. minPoint to INF, INF, INF +// 2. maxPoint to -INF, -INF, -INF +data class BoundingBox(val minPoint: Tuple = MaxPoint, val maxPoint: Tuple = MinPoint) { + init { + if (!minPoint.isPoint()) + throw IllegalArgumentException("BoundingBox minPoint is not a point: $minPoint.") + if (!maxPoint.isPoint()) + throw IllegalArgumentException("BoundingBox maxPoint is not a point: $maxPoint.") + } + + val isEmpty: Boolean by lazy { + minPoint.x > maxPoint.x || minPoint.y > maxPoint.y || minPoint.z > maxPoint.z + } + val isNotEmpty: Boolean by lazy { + !isEmpty + } + + private fun add(point: Tuple): BoundingBox { + if (!point.isPoint()) + throw IllegalArgumentException("Tried to add vector to BoundingBox: $point.") + return BoundingBox( + Tuple.point(min(minPoint.x, point.x), min(minPoint.y, point.y), min(minPoint.z, point.z)), + Tuple.point(max(maxPoint.x, point.x), max(maxPoint.y, point.y), max(maxPoint.z, point.z)) + ) + } + + fun merge(box: BoundingBox): BoundingBox = + add(box.minPoint).add(box.maxPoint) + + fun transform(transformation: Matrix): BoundingBox { + if (!transformation.isTransformation()) + throw IllegalArgumentException("Cannot transform a bounding box by a non-transform matrix.") + val corners = listOf( + minPoint, + Tuple.point(maxPoint.x, minPoint.y, minPoint.z), + Tuple.point(maxPoint.x, minPoint.y, maxPoint.z), + Tuple.point(minPoint.x, minPoint.y, maxPoint.z), + Tuple.point(minPoint.x, maxPoint.y, minPoint.z), + Tuple.point(maxPoint.x, maxPoint.y, minPoint.z), + maxPoint, + Tuple.point(minPoint.x, maxPoint.y, maxPoint.z) + ) + + val transformedCorners = corners.map { transformation * it } + + // Find the new minPoint and maxPoint. + val minX = transformedCorners.fold(Double.POSITIVE_INFINITY) { curr, p -> if (p.x < curr) p.x else curr } + val minY = transformedCorners.fold(Double.POSITIVE_INFINITY) { curr, p -> if (p.y < curr) p.y else curr } + val minZ = transformedCorners.fold(Double.POSITIVE_INFINITY) { curr, p -> if (p.z < curr) p.z else curr } + val maxX = transformedCorners.fold(Double.NEGATIVE_INFINITY) { curr, p -> if (p.x > curr) p.x else curr } + val maxY = transformedCorners.fold(Double.NEGATIVE_INFINITY) { curr, p -> if (p.y > curr) p.y else curr } + val maxZ = transformedCorners.fold(Double.NEGATIVE_INFINITY) { curr, p -> if (p.z > curr) p.z else curr } + + return BoundingBox(Tuple.point(minX, minY, minZ), Tuple.point(maxX, maxY, maxZ)) + } + + // Check axis for AABB (axis aligned bounding box). + // minimum and maximum specify the minimum and maximum values on the axis for this bounding box. + private fun checkAxis(origin: Double, direction: Double, minimum: Double, maximum: Double): Pair { + val tMin = (minimum - origin) / direction + val tMax = (maximum - origin) / direction + return if (tMin > tMax) Pair(tMax, tMin) else Pair(tMin, tMax) + } + + // Check if the ray intersects this bounding box, and if it does, return the t-values. + internal fun intersects(rayLocal: Ray): List { + val (xtMin, xtMax) = checkAxis(rayLocal.origin.x, rayLocal.direction.x, minPoint.x, maxPoint.x) + val (ytMin, ytMax) = checkAxis(rayLocal.origin.y, rayLocal.direction.y, minPoint.y, maxPoint.y) + val (ztMin, ztMax) = checkAxis(rayLocal.origin.z, rayLocal.direction.z, minPoint.z, maxPoint.z) + + val tMin = maxOf(xtMin, ytMin, ztMin) + val tMax = minOf(xtMax, ytMax, ztMax) + + return if (tMin <= tMax) + listOf(tMin, tMax) + else + emptyList() + } + operator fun contains(point: Tuple): Boolean { + if (!point.isPoint()) + throw IllegalArgumentException("BoundingBox contains passed vector: $point.") + return point.x >= minPoint.x && point.y >= minPoint.y && point.z >= minPoint.z && + point.x <= maxPoint.x && point.y <= maxPoint.y && point.z <= maxPoint.z + } + + operator fun contains(box: BoundingBox): Boolean = + contains(box.minPoint) && contains(box.maxPoint) + + companion object { + internal val MaxPoint = Tuple.point( + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY + ) + internal val MinPoint = Tuple.point( + Double.NEGATIVE_INFINITY, + Double.NEGATIVE_INFINITY, + Double.NEGATIVE_INFINITY + ) + + internal val Empty = BoundingBox(MaxPoint, MinPoint) + } +} diff --git a/src/main/kotlin/math/color.kt b/src/main/kotlin/math/Color.kt similarity index 100% rename from src/main/kotlin/math/color.kt rename to src/main/kotlin/math/Color.kt diff --git a/src/main/kotlin/math/computations.kt b/src/main/kotlin/math/Computations.kt similarity index 100% rename from src/main/kotlin/math/computations.kt rename to src/main/kotlin/math/Computations.kt diff --git a/src/main/kotlin/math/intersection.kt b/src/main/kotlin/math/Intersection.kt similarity index 100% rename from src/main/kotlin/math/intersection.kt rename to src/main/kotlin/math/Intersection.kt diff --git a/src/main/kotlin/math/matrix.kt b/src/main/kotlin/math/Matrix.kt similarity index 100% rename from src/main/kotlin/math/matrix.kt rename to src/main/kotlin/math/Matrix.kt diff --git a/src/main/kotlin/math/ray.kt b/src/main/kotlin/math/Ray.kt similarity index 100% rename from src/main/kotlin/math/ray.kt rename to src/main/kotlin/math/Ray.kt diff --git a/src/main/kotlin/math/shared.kt b/src/main/kotlin/math/Shared.kt similarity index 100% rename from src/main/kotlin/math/shared.kt rename to src/main/kotlin/math/Shared.kt diff --git a/src/main/kotlin/math/transformable.kt b/src/main/kotlin/math/Transformable.kt similarity index 100% rename from src/main/kotlin/math/transformable.kt rename to src/main/kotlin/math/Transformable.kt diff --git a/src/main/kotlin/math/tuple.kt b/src/main/kotlin/math/Tuple.kt similarity index 100% rename from src/main/kotlin/math/tuple.kt rename to src/main/kotlin/math/Tuple.kt diff --git a/src/main/kotlin/output/canvas.kt b/src/main/kotlin/output/Canvas.kt similarity index 100% rename from src/main/kotlin/output/canvas.kt rename to src/main/kotlin/output/Canvas.kt diff --git a/src/main/kotlin/output/show.kt b/src/main/kotlin/output/Show.kt similarity index 100% rename from src/main/kotlin/output/show.kt rename to src/main/kotlin/output/Show.kt diff --git a/src/main/kotlin/pattern/blendedpattern.kt b/src/main/kotlin/pattern/BlendedPattern.kt similarity index 100% rename from src/main/kotlin/pattern/blendedpattern.kt rename to src/main/kotlin/pattern/BlendedPattern.kt diff --git a/src/main/kotlin/pattern/checkerpattern.kt b/src/main/kotlin/pattern/CheckerPattern.kt similarity index 100% rename from src/main/kotlin/pattern/checkerpattern.kt rename to src/main/kotlin/pattern/CheckerPattern.kt diff --git a/src/main/kotlin/pattern/gradientpattern.kt b/src/main/kotlin/pattern/GradientPattern.kt similarity index 100% rename from src/main/kotlin/pattern/gradientpattern.kt rename to src/main/kotlin/pattern/GradientPattern.kt diff --git a/src/main/kotlin/pattern/noisepattern.kt b/src/main/kotlin/pattern/NoisePattern.kt similarity index 100% rename from src/main/kotlin/pattern/noisepattern.kt rename to src/main/kotlin/pattern/NoisePattern.kt diff --git a/src/main/kotlin/pattern/pattern.kt b/src/main/kotlin/pattern/Pattern.kt similarity index 100% rename from src/main/kotlin/pattern/pattern.kt rename to src/main/kotlin/pattern/Pattern.kt diff --git a/src/main/kotlin/pattern/perlinnoisepattern.kt b/src/main/kotlin/pattern/PerlinNoisePattern.kt similarity index 100% rename from src/main/kotlin/pattern/perlinnoisepattern.kt rename to src/main/kotlin/pattern/PerlinNoisePattern.kt diff --git a/src/main/kotlin/pattern/ringpattern.kt b/src/main/kotlin/pattern/RingPattern.kt similarity index 100% rename from src/main/kotlin/pattern/ringpattern.kt rename to src/main/kotlin/pattern/RingPattern.kt diff --git a/src/main/kotlin/pattern/simplexnoisepattern.kt b/src/main/kotlin/pattern/SimplexNoisePattern.kt similarity index 100% rename from src/main/kotlin/pattern/simplexnoisepattern.kt rename to src/main/kotlin/pattern/SimplexNoisePattern.kt diff --git a/src/main/kotlin/pattern/solidpattern.kt b/src/main/kotlin/pattern/SolidPattern.kt similarity index 96% rename from src/main/kotlin/pattern/solidpattern.kt rename to src/main/kotlin/pattern/SolidPattern.kt index 4084c1f..f6550d2 100644 --- a/src/main/kotlin/pattern/solidpattern.kt +++ b/src/main/kotlin/pattern/SolidPattern.kt @@ -3,7 +3,6 @@ package pattern // By Sebastian Raaphorst, 2023. import math.Color -import math.Matrix import math.Tuple class SolidPattern(val color: Color): Pattern() { diff --git a/src/main/kotlin/pattern/stripedpattern.kt b/src/main/kotlin/pattern/StripedPattern.kt similarity index 100% rename from src/main/kotlin/pattern/stripedpattern.kt rename to src/main/kotlin/pattern/StripedPattern.kt diff --git a/src/main/kotlin/scene/camera.kt b/src/main/kotlin/scene/Camera.kt similarity index 89% rename from src/main/kotlin/scene/camera.kt rename to src/main/kotlin/scene/Camera.kt index 449e6f8..cad7979 100644 --- a/src/main/kotlin/scene/camera.kt +++ b/src/main/kotlin/scene/Camera.kt @@ -9,13 +9,16 @@ import output.Canvas import kotlin.math.tan // Map a 3D scene onto a 2D canvas. -data class Camera(val hSize: Int, val vSize: Int, val fov: Double, val transformation: Matrix = Matrix.I) { +class Camera(private val hSize: Int, + private val vSize: Int, + fov: Number, + internal val transformation: Matrix = Matrix.I) { init { if (!transformation.isTransformation()) throw IllegalArgumentException("Illegal camera transformation:\n${transformation.show()}") } - private val halfView = tan(fov / 2.0) + private val halfView = tan(fov.toDouble() / 2.0) private val aspect = hSize.toDouble() / vSize.toDouble() private val halfWidth = if (aspect >= 1) halfView else halfView * aspect private val halfHeight = if (aspect >= 1) halfView / aspect else halfView diff --git a/src/main/kotlin/scene/world.kt b/src/main/kotlin/scene/World.kt similarity index 99% rename from src/main/kotlin/scene/world.kt rename to src/main/kotlin/scene/World.kt index 4bfa04b..9f78a13 100644 --- a/src/main/kotlin/scene/world.kt +++ b/src/main/kotlin/scene/World.kt @@ -4,8 +4,6 @@ import light.Light import light.PointLight import material.Material import math.* -import pattern.Pattern -import shapes.Plane import shapes.Shape import shapes.Sphere import kotlin.math.sqrt diff --git a/src/main/kotlin/shapes/Cappable.kt b/src/main/kotlin/shapes/Cappable.kt new file mode 100644 index 0000000..05af572 --- /dev/null +++ b/src/main/kotlin/shapes/Cappable.kt @@ -0,0 +1,69 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.almostEquals +import math.Intersection +import math.Matrix +import math.Ray +import kotlin.math.sqrt + +// An object like a Cylinder or Cone that can be limited in length and cappable. +abstract class Cappable( + internal val minimum: Double = Double.NEGATIVE_INFINITY, + internal val maximum: Double = Double.POSITIVE_INFINITY, + internal val closed: Boolean = false, + transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null + ): Shape (transformation, material, castsShadow, parent) { + + private fun checkCap(rayLocal: Ray, radius: Double, t: Double): Boolean { + val x = rayLocal.origin.x + t * rayLocal.direction.x + val z = rayLocal.origin.z + t * rayLocal.direction.z + return x * x + z * z <= radius * radius + } + + internal fun intersectCaps(rayLocal: Ray, minRadius: Double, maxRadius: Double): List { + if (!closed or almostEquals(0, rayLocal.direction.y)) + return emptyList() + + val tMin = (minimum - rayLocal.origin.y) / rayLocal.direction.y + val tMax = (maximum - rayLocal.origin.y) / rayLocal.direction.y + + return when(Pair(checkCap(rayLocal, minRadius, tMin), checkCap(rayLocal, maxRadius, tMax))) { + Pair(true, true) -> listOf(Intersection(tMin, this), Intersection(tMax, this)) + Pair(true, false) -> listOf(Intersection(tMin, this)) + Pair(false, true) -> listOf(Intersection(tMax, this)) + else -> emptyList() + } + } + + internal fun intersectBody(rayLocal: Ray, a: Double, b: Double, c: Double): List { + val disc = b * b - 4 * a * c + + if (disc < 0) + emptyList() + + val sqrtDisc = sqrt(disc) + val (t0, t1) = run { + val t0Tmp = (-b - sqrtDisc) / (2 * a) + val t1Tmp = (-b + sqrtDisc) / (2 * a) + if (t0Tmp <= t1Tmp) Pair(t0Tmp, t1Tmp) else Pair(t1Tmp, t0Tmp) + } + + // Check for intersections with the body of the cone. + val y0 = rayLocal.origin.y + t0 * rayLocal.direction.y + val y1 = rayLocal.origin.y + t1 * rayLocal.direction.y + val t0Int = minimum < y0 && y0 < maximum + val t1Int = minimum < y1 && y1 < maximum + return when (Pair(t0Int, t1Int)) { + Pair(true, true) -> listOf(Intersection(t0, this), Intersection(t1, this)) + Pair(true, false) -> listOf(Intersection(t0, this)) + Pair(false, true) -> listOf(Intersection(t1, this)) + else -> emptyList() + } + } +} diff --git a/src/main/kotlin/shapes/Cone.kt b/src/main/kotlin/shapes/Cone.kt new file mode 100644 index 0000000..efa209f --- /dev/null +++ b/src/main/kotlin/shapes/Cone.kt @@ -0,0 +1,70 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import math.Intersection +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.sqrt + +class Cone(minimum: Number = Double.NEGATIVE_INFINITY, + maximum: Number = Double.POSITIVE_INFINITY, + closed: Boolean = false, + transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null): + Cappable(minimum.toDouble(), maximum.toDouble(), closed, transformation, material, castsShadow, parent) { + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Cone(minimum, maximum, closed, transformation, objMaterial, castsShadow, parent) + + override fun withMaterial(material: Material): Shape = + Cone(minimum, maximum, closed, transformation, material, castsShadow, parent) + + override fun localIntersect(rayLocal: Ray): List { + val a = rayLocal.direction.x * rayLocal.direction.x - + rayLocal.direction.y * rayLocal.direction.y + + rayLocal.direction.z * rayLocal.direction.z + val b = 2 * rayLocal.origin.x * rayLocal.direction.x - + 2 * rayLocal.origin.y * rayLocal.direction.y + + 2 * rayLocal.origin.z * rayLocal.direction.z + val c = rayLocal.origin.x * rayLocal.origin.x - + rayLocal.origin.y * rayLocal.origin.y + + rayLocal.origin.z * rayLocal.origin.z + + // Only check for intersections with the body of the cylinder if a is not near 0. + val xs1 = if (almostEquals(0.0, a)) { + if (almostEquals(0.0, b)) emptyList() + else listOf(Intersection(-c / (2 * b), this)) + } else intersectBody(rayLocal, a, b, c) + + // Check for intersections with the caps of the cylinder, if appropriate. + val xs2 = intersectCaps(rayLocal, minimum, maximum) + + return xs1 + xs2 + } + + override fun localNormalAt(localPoint: Tuple): Tuple { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + + // Check first if we are at the caps if they apply, and otherwise if we are on the body. + return if (dist < 1.0 && localPoint.y >= maximum - DEFAULT_PRECISION) + Tuple.VY + else if (dist < 1.0 && localPoint.y <= minimum + DEFAULT_PRECISION) + -Tuple.VY + else { + val y0 = sqrt(dist) + val y = if (localPoint.y > 0) -y0 else y0 + return Tuple.vector(localPoint.x, y, localPoint.z) + } + } + + override val bounds: BoundingBox by lazy { + val r = max(this.minimum.absoluteValue, this.maximum.absoluteValue) + BoundingBox(Tuple.point(-r, minimum, -r), Tuple.point(r, maximum, r)) + } +} diff --git a/src/main/kotlin/shapes/Cube.kt b/src/main/kotlin/shapes/Cube.kt new file mode 100644 index 0000000..9fffca8 --- /dev/null +++ b/src/main/kotlin/shapes/Cube.kt @@ -0,0 +1,40 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import math.Intersection +import kotlin.math.absoluteValue + +class Cube(transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null): + Shape(transformation, material, castsShadow, parent) { + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Cube(transformation, objMaterial, castsShadow, parent) + + override fun withMaterial(material: Material): Shape = + Cube(transformation, material, castsShadow, parent) + + override fun localIntersect(rayLocal: Ray): List = + bounds.intersects(rayLocal).map { Intersection(it, this) } + + override fun localNormalAt(localPoint: Tuple): Tuple { + val posX = localPoint.x.absoluteValue + val posY = localPoint.y.absoluteValue + val posZ = localPoint.z.absoluteValue + return when(maxOf(posX, posY, posZ)) { + posX -> Tuple.vector(localPoint.x, 0,0) + posY -> Tuple.vector(0, localPoint.y, 0) + else -> Tuple.vector(0, 0, localPoint.z) + } + } + + override val bounds: BoundingBox by lazy { + BoundingBox(Tuple.point(-1, -1, -1), Tuple.point(1, 1, 1)) + } +} diff --git a/src/main/kotlin/shapes/Cylinder.kt b/src/main/kotlin/shapes/Cylinder.kt new file mode 100644 index 0000000..09a3d1c --- /dev/null +++ b/src/main/kotlin/shapes/Cylinder.kt @@ -0,0 +1,57 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import math.Intersection + +class Cylinder(minimum: Number = Double.NEGATIVE_INFINITY, + maximum: Number = Double.POSITIVE_INFINITY, + closed: Boolean = false, + transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null): + Cappable(minimum.toDouble(), maximum.toDouble(), closed, transformation, material, castsShadow, parent) { + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Cylinder(minimum, maximum, closed, transformation, objMaterial, castsShadow, parent) + + override fun withMaterial(material: Material): Shape = + Cylinder(minimum, maximum, closed, transformation, material, castsShadow, parent) + + override fun localIntersect(rayLocal: Ray): List { + val a = rayLocal.direction.x * rayLocal.direction.x + rayLocal.direction.z * rayLocal.direction.z + + // Only check for intersections with the body of the cylinder if a is not near 0. + val xs1 = if (almostEquals(0.0, a)) emptyList() + else { + val b = 2 * rayLocal.origin.x * rayLocal.direction.x + 2 * rayLocal.origin.z * rayLocal.direction.z + val c = rayLocal.origin.x * rayLocal.origin.x + rayLocal.origin.z * rayLocal.origin.z - 1 + intersectBody(rayLocal, a, b, c) + } + + // Check for intersections with the caps of the cylinder, if appropriate. + val xs2 = intersectCaps(rayLocal, 1.0, 1.0) + + return xs1 + xs2 + } + + override fun localNormalAt(localPoint: Tuple): Tuple { + val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z + + // Check first if we are at the caps if they apply, and otherwise if we are on the body. + return if (dist < 1.0 && localPoint.y >= maximum - DEFAULT_PRECISION) + Tuple.VY + else if (dist < 1.0 && localPoint.y <= minimum + DEFAULT_PRECISION) + -Tuple.VY + else + Tuple.vector(localPoint.x, 0, localPoint.z) + } + + override val bounds: BoundingBox by lazy { + BoundingBox(Tuple.point(-1, minimum, -1), Tuple.point(1, maximum, 1)) + } +} diff --git a/src/main/kotlin/shapes/Group.kt b/src/main/kotlin/shapes/Group.kt new file mode 100644 index 0000000..f34a8ff --- /dev/null +++ b/src/main/kotlin/shapes/Group.kt @@ -0,0 +1,84 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import math.Intersection + +class Group(transformation: Matrix = Matrix.I, + material: Material? = null, + children: List = emptyList(), + castsShadow: Boolean = true, + parent: Shape? = null): + Shape(transformation, material, castsShadow, parent) { + + // Make copies of all the children to backreference this as their parent. + val children = run { children.map { it.withParent(this) } } + val size = children.size + val isEmpty = children.isEmpty() + val isNotEmpty = children.isNotEmpty() + + operator fun get(idx: Int): Shape = + children[idx] + + operator fun contains(s: Shape): Boolean = + s in children + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Group(transformation, objMaterial, children, castsShadow, parent) + + fun withTransformation(transformation: Matrix): Shape { + if (!transformation.isTransformation()) + throw IllegalArgumentException("Shapes must have 4x4 transformation matrices:\n" + + "\tShape: ${javaClass.name}\nTransformation:\n${transformation.show()}") + return Group(transformation, material, children, castsShadow, parent) + } + + fun forEach(f: (Shape) -> Unit) { + children.forEach(f) + } + + fun map(f: (Shape) -> T): Iterable = + children.map(f) + + fun all(predicate: (Shape) -> Boolean): Boolean = + children.all(predicate) + + fun none(predicate: (Shape) -> Boolean): Boolean = + children.none(predicate) + + fun any(predicate: (Shape) -> Boolean): Boolean = + children.any(predicate) + + fun zip(other: Iterable): Iterable> = + children.zip(other) + + fun zip(group: Group): Iterable> = + children.zip(group.children) + + fun withIndex(group: Group): Iterable> = + children.withIndex() + + override fun withMaterial(material: Material): Shape = + Group(transformation, material, children.map { it.withMaterial(material) }, castsShadow, parent) + + // Only process children if the ray intersects the bounding box for this group. + override fun localIntersect(rayLocal: Ray): List = + if (bounds.intersects(rayLocal).isNotEmpty()) + children.flatMap { it.intersect(rayLocal) }.sortedBy { it.t } + else + emptyList() + + override fun localNormalAt(localPoint: Tuple): Tuple = + throw NotImplementedError("Groups do not have local normals.") + + override val bounds: BoundingBox by lazy { + // At first, the bounds are a completely empty box, with: + // 1. minPoint at INF, INF, INF + // 2. maxPoint at -INF, -INF, -INF. + // We make the space larger from the children. + children.fold(BoundingBox.Empty) { curr, shape -> curr.merge(shape.parentBounds) } + } +} diff --git a/src/main/kotlin/shapes/Plane.kt b/src/main/kotlin/shapes/Plane.kt new file mode 100644 index 0000000..8e34c2c --- /dev/null +++ b/src/main/kotlin/shapes/Plane.kt @@ -0,0 +1,38 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import kotlin.math.absoluteValue + +class Plane(transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null): + Shape(transformation, material, castsShadow, parent) { + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Plane(transformation, objMaterial, castsShadow, parent) + + override fun withMaterial(material: Material): Shape = + Plane(transformation, material, castsShadow, parent) + + override fun localIntersect(rayLocal: Ray): List { + if (rayLocal.direction.y.absoluteValue < DEFAULT_PRECISION) + return emptyList() + val t = -(rayLocal.origin.y) / rayLocal.direction.y + return listOf(Intersection(t, this)) + } + + override fun localNormalAt(localPoint: Tuple): Tuple = + Tuple.VY + + override val bounds: BoundingBox by lazy { + BoundingBox( + Tuple.point(Double.NEGATIVE_INFINITY, 0, Double.NEGATIVE_INFINITY), + Tuple.point(Double.POSITIVE_INFINITY, 0, Double.POSITIVE_INFINITY) + ) + } +} diff --git a/src/main/kotlin/shapes/shape.kt b/src/main/kotlin/shapes/Shape.kt similarity index 51% rename from src/main/kotlin/shapes/shape.kt rename to src/main/kotlin/shapes/Shape.kt index ea99aea..cfb5d9a 100644 --- a/src/main/kotlin/shapes/shape.kt +++ b/src/main/kotlin/shapes/Shape.kt @@ -3,15 +3,15 @@ package shapes // By Sebastian Raaphorst, 2023. import material.Material +import math.* import math.Intersection -import math.Matrix -import math.Ray -import math.Tuple import java.util.UUID +import kotlin.math.PI abstract class Shape(val transformation: Matrix, - val material: Material, - val castsShadow: Boolean = true, + material: Material? = null, + val castsShadow: Boolean, + val parent: Shape?, private val id: UUID = UUID.randomUUID()) { init { if (!transformation.isTransformation()) @@ -19,15 +19,31 @@ abstract class Shape(val transformation: Matrix, "\tShape: ${javaClass.name}\nTransformation:\n${transformation.show()}") } + // We need to store the parameter passed in for material here in order to propagate it correctly. + // We will access it below: if an object does not have a material, it will try to see if it has a parent + // with a material. + protected val objMaterial = material + + val material: Material + get() = objMaterial ?: (parent?.objMaterial ?: DefaultMaterial) + + // This method should only be invoked by Groups containing the object. + internal abstract fun withParent(parent: Shape? = null): Shape + + // This method creates a copy of the object with the specified material. + abstract fun withMaterial(material: Material): Shape + // Convert a point from world space to object space. // Later on, we use parent here. - fun worldToLocal(tuple: Tuple): Tuple = - transformation.inverse * tuple + internal fun worldToLocal(tuple: Tuple): Tuple = + transformation.inverse * (parent?.worldToLocal(tuple) ?: tuple) // Convert a normal vector from object space to world space. // Later on, we will use parent here. - private fun normalToWorld(localNormal: Tuple): Tuple = - (transformation.inverse.transpose * localNormal).toVector().normalized + internal fun normalToWorld(localNormal: Tuple): Tuple { + val normal = (transformation.inverse.transpose * localNormal).toVector().normalized + return parent?.normalToWorld(normal) ?: normal + } // The intersect method transforms the ray to object space and then passes // it to localNormalAt, which should comprise the concrete implementation of @@ -38,7 +54,7 @@ abstract class Shape(val transformation: Matrix, internal abstract fun localIntersect(rayLocal: Ray): List - // normalAt transforms the point to object space and passes it to localNormalAt + // normalAt transforms the point from world to object (local) space and passes it to localNormalAt // which should comprise the concrete implementation of calculating the normal vector // at the point for the Shape. Then normalAt transforms it back into world space. internal fun normalAt(worldPoint: Tuple): Tuple { @@ -52,21 +68,19 @@ abstract class Shape(val transformation: Matrix, // Convert back to world space. return normalToWorld(localNormal) } - internal abstract fun localNormalAt(localPoint: Tuple): Tuple - - // We want shapes to be considered only equal if they represent exactly the same shape. - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Shape) return false + // Normal at a point in object (local) space. + // Normal should be returned in local space, and normalAt handles transforming it back to world space. + internal abstract fun localNormalAt(localPoint: Tuple): Tuple - if (id != other.id) return false - if (transformation != other.transformation) return false - if (material != other.material) return false + // Untransformed bounds for each Shape type. + internal abstract val bounds: BoundingBox - return true + internal val parentBounds: BoundingBox by lazy { + bounds.transform(transformation) } - override fun hashCode(): Int = - 31 * (31 * transformation.hashCode() + material.hashCode()) + id.hashCode() + companion object { + private val DefaultMaterial = Material() + } } diff --git a/src/main/kotlin/shapes/sphere.kt b/src/main/kotlin/shapes/Sphere.kt similarity index 64% rename from src/main/kotlin/shapes/sphere.kt rename to src/main/kotlin/shapes/Sphere.kt index 73fc503..8724dc7 100644 --- a/src/main/kotlin/shapes/sphere.kt +++ b/src/main/kotlin/shapes/Sphere.kt @@ -3,14 +3,23 @@ package shapes // By Sebastian Raaphorst, 2023. import material.Material +import math.* import math.Intersection -import math.Matrix -import math.Ray -import math.Tuple import kotlin.math.sqrt -class Sphere(transformation: Matrix = Matrix.I, material: Material = Material(), castsShadow: Boolean = true): - Shape(transformation, material, castsShadow) { +class Sphere(transformation: Matrix = Matrix.I, + material: Material? = null, + castsShadow: Boolean = true, + parent: Shape? = null): + Shape(transformation, material, castsShadow, parent) { + + // Note due to Kotlin semantics, we have to use objMaterial here. + override fun withParent(parent: Shape?): Shape = + Sphere(transformation, objMaterial, castsShadow, parent) + + override fun withMaterial(material: Material): Shape = + Sphere(transformation, material, castsShadow, parent) + override fun localIntersect(rayLocal: Ray): List { val sphereToRay = rayLocal.origin - Tuple.PZERO @@ -32,6 +41,10 @@ class Sphere(transformation: Matrix = Matrix.I, material: Material = Material(), override fun localNormalAt(localPoint: Tuple): Tuple = localPoint - Tuple.PZERO + override val bounds: BoundingBox by lazy { + BoundingBox(Tuple.point(-1, -1, -1), Tuple.point(1, 1, 1)) + } + companion object { internal fun glassSphere(transformation: Matrix = Matrix.I, transparency: Double = 1.0, diff --git a/src/main/kotlin/shapes/cone.kt b/src/main/kotlin/shapes/cone.kt deleted file mode 100644 index a56316c..0000000 --- a/src/main/kotlin/shapes/cone.kt +++ /dev/null @@ -1,106 +0,0 @@ -package shapes - -// By Sebastian Raaphorst, 2023. - -import material.Material -import math.* -import math.Intersection -import kotlin.math.sqrt - -class Cone(minimum: Number = Double.NEGATIVE_INFINITY, - maximum: Number = Double.POSITIVE_INFINITY, - val closed: Boolean = false, - transformation: Matrix = Matrix.I, - material: Material = Material(), - castsShadow: Boolean = true): - Shape(transformation, material, castsShadow) { - - val minimum = minimum.toDouble() - val maximum = maximum.toDouble() - - override fun localIntersect(rayLocal: Ray): List { - val a = rayLocal.direction.x * rayLocal.direction.x - - rayLocal.direction.y * rayLocal.direction.y + - rayLocal.direction.z * rayLocal.direction.z - val b = 2 * rayLocal.origin.x * rayLocal.direction.x - - 2 * rayLocal.origin.y * rayLocal.direction.y + - 2 * rayLocal.origin.z * rayLocal.direction.z - val c = rayLocal.origin.x * rayLocal.origin.x - - rayLocal.origin.y * rayLocal.origin.y + - rayLocal.origin.z * rayLocal.origin.z - - // Only check for intersections with the body of the cylinder if a is not near 0. - val xs1 = if (almostEquals(0.0, a)) { - if (almostEquals(0.0, b)) emptyList() - else listOf(Intersection(-c / (2 * b), this)) - } - else { - val disc = b * b - 4 * a * c - - if (disc < 0) - emptyList() - - val sqrtDisc = sqrt(disc) - val (t0, t1) = run { - val t0Tmp = (-b - sqrtDisc) / (2 * a) - val t1Tmp = (-b + sqrtDisc) / (2 * a) - if (t0Tmp <= t1Tmp) Pair(t0Tmp, t1Tmp) else Pair(t1Tmp, t0Tmp) - } - - // Check for intersections with the body of the cylinder. - val y0 = rayLocal.origin.y + t0 * rayLocal.direction.y - val y1 = rayLocal.origin.y + t1 * rayLocal.direction.y - val t0Int = minimum < y0 && y0 < maximum - val t1Int = minimum < y1 && y1 < maximum - when (Pair(t0Int, t1Int)) { - Pair(true, true) -> listOf(Intersection(t0, this), Intersection(t1, this)) - Pair(true, false) -> listOf(Intersection(t0, this)) - Pair(false, true) -> listOf(Intersection(t1, this)) - else -> emptyList() - } - } - - // Check for intersections with the caps of the cylinder, if appropriate. - val xs2 = intersectCaps(rayLocal) - - return xs1 + xs2 - } - - // Helper function to check to see if intersection at t is within a radius of 1 - // (i.e. radius of cylinder) from the y-axis. - private fun checkCap(ray: Ray, t: Double, y: Double): Boolean { - val x = ray.origin.x + t * ray.direction.x - val z = ray.origin.z + t * ray.direction.z - return x * x + z * z <= y * y - } - - // Return any intersections with the caps of a closed cylinder. - private fun intersectCaps(ray: Ray): List { - if (!closed or almostEquals(0, ray.direction.y)) - return emptyList() - - val tMin = (minimum - ray.origin.y) / ray.direction.y - val tMax = (maximum - ray.origin.y) / ray.direction.y - return when(Pair(checkCap(ray, tMin, minimum), checkCap(ray, tMax, maximum))) { - Pair(true, true) -> listOf(Intersection(tMin, this), Intersection(tMax, this)) - Pair(true, false) -> listOf(Intersection(tMin, this)) - Pair(false, true) -> listOf(Intersection(tMax, this)) - else -> emptyList() - } - } - - override fun localNormalAt(localPoint: Tuple): Tuple { - val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z - - // Check first if we are at the caps if they apply, and otherwise if we are on the body. - return if (dist < 1.0 && localPoint.y >= maximum - DEFAULT_PRECISION) - Tuple.VY - else if (dist < 1.0 && localPoint.y <= minimum + DEFAULT_PRECISION) - -Tuple.VY - else { - val y0 = sqrt(dist) - val y = if (localPoint.y > 0) -y0 else y0 - return Tuple.vector(localPoint.x, y, localPoint.z) - } - } -} diff --git a/src/main/kotlin/shapes/cube.kt b/src/main/kotlin/shapes/cube.kt deleted file mode 100644 index e874ba6..0000000 --- a/src/main/kotlin/shapes/cube.kt +++ /dev/null @@ -1,42 +0,0 @@ -package shapes - -// By Sebastian Raaphorst, 2023. - -import material.Material -import math.* -import math.Intersection -import kotlin.math.absoluteValue - -class Cube(transformation: Matrix = Matrix.I, material: Material = Material(), castsShadow: Boolean = true): - Shape(transformation, material, castsShadow) { - override fun localIntersect(rayLocal: Ray): List { - val (xtMin, xtMax) = checkAxis(rayLocal.origin.x, rayLocal.direction.x) - val (ytMin, ytMax) = checkAxis(rayLocal.origin.y, rayLocal.direction.y) - val (ztMin, ztMax) = checkAxis(rayLocal.origin.z, rayLocal.direction.z) - - val tMin = maxOf(xtMin, ytMin, ztMin) - val tMax = minOf(xtMax, ytMax, ztMax) - - return if (tMin <= tMax) - listOf(Intersection(tMin, this), Intersection(tMax, this)) - else - emptyList() - } - - private fun checkAxis(origin: Double, direction: Double): Pair { - val tMin = (-1 - origin) / direction - val tMax = (1 - origin) / direction - return if (tMin > tMax) Pair(tMax, tMin) else Pair(tMin, tMax) - } - - override fun localNormalAt(localPoint: Tuple): Tuple { - val posX = localPoint.x.absoluteValue - val posY = localPoint.y.absoluteValue - val posZ = localPoint.z.absoluteValue - return when(maxOf(posX, posY, posZ)) { - posX -> Tuple.vector(localPoint.x, 0,0) - posY -> Tuple.vector(0, localPoint.y, 0) - else -> Tuple.vector(0, 0, localPoint.z) - } - } -} diff --git a/src/main/kotlin/shapes/cylinder.kt b/src/main/kotlin/shapes/cylinder.kt deleted file mode 100644 index 5cb3ad0..0000000 --- a/src/main/kotlin/shapes/cylinder.kt +++ /dev/null @@ -1,95 +0,0 @@ -package shapes - -// By Sebastian Raaphorst, 2023. - -import material.Material -import math.* -import math.Intersection -import kotlin.math.sqrt - -class Cylinder(minimum: Number = Double.NEGATIVE_INFINITY, - maximum: Number = Double.POSITIVE_INFINITY, - val closed: Boolean = false, - transformation: Matrix = Matrix.I, - material: Material = Material(), - castsShadow: Boolean = true): - Shape(transformation, material, castsShadow) { - - val minimum = minimum.toDouble() - val maximum = maximum.toDouble() - - override fun localIntersect(rayLocal: Ray): List { - val a = rayLocal.direction.x * rayLocal.direction.x + rayLocal.direction.z * rayLocal.direction.z - - // Only check for intersections with the body of the cylinder if a is not near 0. - val xs1 = if (almostEquals(0.0, a)) emptyList() - else { - - val b = 2 * rayLocal.origin.x * rayLocal.direction.x + 2 * rayLocal.origin.z * rayLocal.direction.z - val c = rayLocal.origin.x * rayLocal.origin.x + rayLocal.origin.z * rayLocal.origin.z - 1 - val disc = b * b - 4 * a * c - - if (disc < 0) - emptyList() - - val sqrtDisc = sqrt(disc) - val (t0, t1) = run { - val t0Tmp = (-b - sqrtDisc) / (2 * a) - val t1Tmp = (-b + sqrtDisc) / (2 * a) - if (t0Tmp <= t1Tmp) Pair(t0Tmp, t1Tmp) else Pair(t1Tmp, t0Tmp) - } - - // Check for intersections with the body of the cylinder. - val y0 = rayLocal.origin.y + t0 * rayLocal.direction.y - val y1 = rayLocal.origin.y + t1 * rayLocal.direction.y - val t0Int = minimum < y0 && y0 < maximum - val t1Int = minimum < y1 && y1 < maximum - when (Pair(t0Int, t1Int)) { - Pair(true, true) -> listOf(Intersection(t0, this), Intersection(t1, this)) - Pair(true, false) -> listOf(Intersection(t0, this)) - Pair(false, true) -> listOf(Intersection(t1, this)) - else -> emptyList() - } - } - - // Check for intersections with the caps of the cylinder, if appropriate. - val xs2 = intersectCaps(rayLocal) - - return xs1 + xs2 - } - - // Helper function to check to see if intersection at t is within a radius of 1 - // (i.e. radius of cylinder) from the y-axis. - private fun checkCap(ray: Ray, t: Double): Boolean { - val x = ray.origin.x + t * ray.direction.x - val z = ray.origin.z + t * ray.direction.z - return x * x + z * z <= 1 - } - - // Return any intersections with the caps of a closed cylinder. - private fun intersectCaps(ray: Ray): List { - if (!closed or almostEquals(0, ray.direction.y)) - return emptyList() - - val tMin = (minimum - ray.origin.y) / ray.direction.y - val tMax = (maximum - ray.origin.y) / ray.direction.y - return when(Pair(checkCap(ray, tMin), checkCap(ray, tMax))) { - Pair(true, true) -> listOf(Intersection(tMin, this), Intersection(tMax, this)) - Pair(true, false) -> listOf(Intersection(tMin, this)) - Pair(false, true) -> listOf(Intersection(tMax, this)) - else -> emptyList() - } - } - - override fun localNormalAt(localPoint: Tuple): Tuple { - val dist = localPoint.x * localPoint.x + localPoint.z * localPoint.z - - // Check first if we are at the caps if they apply, and otherwise if we are on the body. - return if (dist < 1.0 && localPoint.y >= maximum - DEFAULT_PRECISION) - Tuple.VY - else if (dist < 1.0 && localPoint.y <= minimum + DEFAULT_PRECISION) - -Tuple.VY - else - Tuple.vector(localPoint.x, 0, localPoint.z) - } -} diff --git a/src/main/kotlin/shapes/plane.kt b/src/main/kotlin/shapes/plane.kt deleted file mode 100644 index dc76430..0000000 --- a/src/main/kotlin/shapes/plane.kt +++ /dev/null @@ -1,20 +0,0 @@ -package shapes - -// By Sebastian Raaphorst, 2023. - -import material.Material -import math.* -import kotlin.math.absoluteValue - -class Plane(transformation: Matrix = Matrix.I, material: Material = Material(), castsShadow: Boolean = true): - Shape(transformation, material, castsShadow) { - override fun localIntersect(rayLocal: Ray): List { - if (rayLocal.direction.y.absoluteValue < DEFAULT_PRECISION) - return emptyList() - val t = -(rayLocal.origin.y) / rayLocal.direction.y - return listOf(Intersection(t, this)) - } - - override fun localNormalAt(localPoint: Tuple): Tuple = - Tuple.VY -} diff --git a/src/test/kotlin/light/pointlighttest.kt b/src/test/kotlin/light/PointLightTest.kt similarity index 100% rename from src/test/kotlin/light/pointlighttest.kt rename to src/test/kotlin/light/PointLightTest.kt diff --git a/src/test/kotlin/material/materialtest.kt b/src/test/kotlin/material/MaterialTest.kt similarity index 100% rename from src/test/kotlin/material/materialtest.kt rename to src/test/kotlin/material/MaterialTest.kt diff --git a/src/test/kotlin/math/colortest.kt b/src/test/kotlin/math/ColorTest.kt similarity index 100% rename from src/test/kotlin/math/colortest.kt rename to src/test/kotlin/math/ColorTest.kt diff --git a/src/test/kotlin/math/intersectiontest.kt b/src/test/kotlin/math/IntersectionTest.kt similarity index 100% rename from src/test/kotlin/math/intersectiontest.kt rename to src/test/kotlin/math/IntersectionTest.kt diff --git a/src/test/kotlin/math/matrixtest.kt b/src/test/kotlin/math/MatrixTest.kt similarity index 100% rename from src/test/kotlin/math/matrixtest.kt rename to src/test/kotlin/math/MatrixTest.kt diff --git a/src/test/kotlin/math/raytest.kt b/src/test/kotlin/math/RayTest.kt similarity index 100% rename from src/test/kotlin/math/raytest.kt rename to src/test/kotlin/math/RayTest.kt diff --git a/src/test/kotlin/math/sharedtest.kt b/src/test/kotlin/math/SharedTest.kt similarity index 100% rename from src/test/kotlin/math/sharedtest.kt rename to src/test/kotlin/math/SharedTest.kt diff --git a/src/test/kotlin/math/tupletest.kt b/src/test/kotlin/math/TupleTest.kt similarity index 100% rename from src/test/kotlin/math/tupletest.kt rename to src/test/kotlin/math/TupleTest.kt diff --git a/src/test/kotlin/output/canvastest.kt b/src/test/kotlin/output/CanvasTest.kt similarity index 98% rename from src/test/kotlin/output/canvastest.kt rename to src/test/kotlin/output/CanvasTest.kt index 98a400c..f497a49 100644 --- a/src/test/kotlin/output/canvastest.kt +++ b/src/test/kotlin/output/CanvasTest.kt @@ -6,7 +6,7 @@ import math.Color import org.junit.jupiter.api.Test import kotlin.test.assertEquals -class TestCanvas { +class CanvasTest { @Test fun `Create a Canvas`() { val c = Canvas(10, 20) diff --git a/src/test/kotlin/pattern/blendedpatterntest.kt b/src/test/kotlin/pattern/BlendedPatternTest.kt similarity index 98% rename from src/test/kotlin/pattern/blendedpatterntest.kt rename to src/test/kotlin/pattern/BlendedPatternTest.kt index d7940a9..427cb38 100644 --- a/src/test/kotlin/pattern/blendedpatterntest.kt +++ b/src/test/kotlin/pattern/BlendedPatternTest.kt @@ -1,5 +1,7 @@ package pattern +// By Sebastian Raaphorst, 2023. + import math.Color import math.Matrix import math.Tuple diff --git a/src/test/kotlin/pattern/checkerpatterntest.kt b/src/test/kotlin/pattern/CheckerPatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/checkerpatterntest.kt rename to src/test/kotlin/pattern/CheckerPatternTest.kt diff --git a/src/test/kotlin/pattern/gradientpatterntest.kt b/src/test/kotlin/pattern/GradientPatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/gradientpatterntest.kt rename to src/test/kotlin/pattern/GradientPatternTest.kt diff --git a/src/test/kotlin/pattern/patterntest.kt b/src/test/kotlin/pattern/PatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/patterntest.kt rename to src/test/kotlin/pattern/PatternTest.kt diff --git a/src/test/kotlin/pattern/ringpatterntest.kt b/src/test/kotlin/pattern/RingPatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/ringpatterntest.kt rename to src/test/kotlin/pattern/RingPatternTest.kt diff --git a/src/test/kotlin/pattern/solidpatterntest.kt b/src/test/kotlin/pattern/SolidPatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/solidpatterntest.kt rename to src/test/kotlin/pattern/SolidPatternTest.kt diff --git a/src/test/kotlin/pattern/stripedpatterntest.kt b/src/test/kotlin/pattern/StripedPatternTest.kt similarity index 100% rename from src/test/kotlin/pattern/stripedpatterntest.kt rename to src/test/kotlin/pattern/StripedPatternTest.kt diff --git a/src/test/kotlin/scene/cameratest.kt b/src/test/kotlin/scene/CameraTest.kt similarity index 100% rename from src/test/kotlin/scene/cameratest.kt rename to src/test/kotlin/scene/CameraTest.kt diff --git a/src/test/kotlin/scene/worldtest.kt b/src/test/kotlin/scene/WorldTest.kt similarity index 100% rename from src/test/kotlin/scene/worldtest.kt rename to src/test/kotlin/scene/WorldTest.kt diff --git a/src/test/kotlin/shapes/conetest.kt b/src/test/kotlin/shapes/ConeTest.kt similarity index 100% rename from src/test/kotlin/shapes/conetest.kt rename to src/test/kotlin/shapes/ConeTest.kt diff --git a/src/test/kotlin/shapes/cubetest.kt b/src/test/kotlin/shapes/CubeTest.kt similarity index 95% rename from src/test/kotlin/shapes/cubetest.kt rename to src/test/kotlin/shapes/CubeTest.kt index 8c9003c..c52c760 100644 --- a/src/test/kotlin/shapes/cubetest.kt +++ b/src/test/kotlin/shapes/CubeTest.kt @@ -58,8 +58,7 @@ class CubeTest { Tuple.vector(0.5345, 0.8018, 0.2673), -Tuple.VZ, -Tuple.VY, -Tuple.VX) - origins.zip(directions).withIndex().forEach { (idx, rayInfo) -> - val (origin, direction) = rayInfo + origins.zip(directions).forEach { (origin, direction) -> val r = Ray(origin, direction) val xs = c.localIntersect(r) assertEquals(0, xs.size) diff --git a/src/test/kotlin/shapes/cylindertest.kt b/src/test/kotlin/shapes/CylinderTest.kt similarity index 100% rename from src/test/kotlin/shapes/cylindertest.kt rename to src/test/kotlin/shapes/CylinderTest.kt diff --git a/src/test/kotlin/shapes/GroupTest.kt b/src/test/kotlin/shapes/GroupTest.kt new file mode 100644 index 0000000..b65863f --- /dev/null +++ b/src/test/kotlin/shapes/GroupTest.kt @@ -0,0 +1,132 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import org.junit.jupiter.api.Test +import kotlin.math.PI +import kotlin.test.* + +class GroupTest { + @Test + fun `New group`() { + val g = Group() + assertEquals(Matrix.I, g.transformation) + assertTrue(g.isEmpty) + } + + @Test + fun `New group with test shape`() { + val s = ShapeTest.TestShape() + val g = Group(children = listOf(s)) + assertEquals(1, g.size) + val sp = g[0] + + // Check that s has changed and been assigned the group as a parent. + assertNotSame(s, sp) + assertEquals(g, sp.parent) + + // Check that g does not contain the initial shape, but does contain the new shape. + assertFalse(s in g) + assertTrue(sp in g) + } + + @Test + fun `Intersect ray with empty group`() { + val g = Group() + val r = Ray(Tuple.PZERO, Tuple.VZ) + val xs = g.localIntersect(r) + assertTrue(xs.isEmpty()) + } + + @Test + fun `Intersect ray with nonempty group`() { + val s1 = Sphere() + val s2 = Sphere(transformation = Matrix.translate(0, 0, -3)) + val s3 = Sphere(transformation = Matrix.translate(5, 0, 0)) + val g = Group(children = listOf(s1, s2, s3)) + + val r = Ray(Tuple.point(0, 0, -5), Tuple.VZ) + val xs = g.localIntersect(r) + assertEquals(4, xs.size) + val sp1 = g[0] + val sp2 = g[1] + assertSame(sp2, xs[0].shape) + assertSame(sp2, xs[1].shape) + assertSame(sp1, xs[2].shape) + assertSame(sp1, xs[3].shape) + } + + @Test + fun `Intersect ray with transformed group`() { + val s = Sphere(transformation = Matrix.translate(5, 0, 0)) + val g = Group(transformation = Matrix.scale(2, 2, 2), children = listOf(s)) + + val r = Ray(Tuple.point(10, 0, -10), Tuple.VZ) + val xs = g.intersect(r) + assertEquals(2, xs.size) + } + + @Test + fun `Calling withXXX properties on groups`() { + val g1 = run { + val s1 = Sphere(transformation = Matrix.translate(-1, -1, -1) * Matrix.scale(0.5, 0.5, 0.5)) + val c1 = Cylinder(transformation = Matrix.rotateY(PI / 2) * Matrix.scale(0.33, 0.33, 0.33)) + Group(children = listOf(s1, c1)) + } + + // g1 is no longer relevant: + // 1. The children of g1g should have g1g as their parent. + // 2. The parent of g1g should be g2. + // 3. The transformations of g1 and g1g should still be the same. + val g2 = Group(Matrix.scale(0.1, 0.1, 0.1), children = listOf(g1)) + val g1g = g2.children[0] as Group + + assertSame(g2, g1g.parent) + g1g.forEach { assertSame(g1g, it.parent) } + g1.zip(g1g).forEach { (s1, s2) -> + assertNotSame(s1, s2) + assertSame(s1.transformation, s2.transformation) + } + + // 1. The children of g1p should have g1p as their parent. + // 2. The parent of g1p should be g2p. + // 3. The transformations of g1 and g1p should still be the same. + val g2p = g2.withTransformation(Matrix.rotateX(PI)) as Group + val g1p = g2p.children[0] as Group + + assertSame(g2p, g1p.parent) + g1p.forEach { assertSame(g1p, it.parent) } + g1.zip(g1p).forEach { (s1, s2) -> + assertNotSame(s1, s2) + assertSame(s1.transformation, s2.transformation) + } + + // Setting a material on g2p should set the exact same material through g2p's children. + val m = Material(Color.BLUE, ambient = 0.5, specular = 0.2, shininess = 100.0) + val g2m = g2p.withMaterial(m) as Group + val g1m = g2m.children[0] as Group + + assertSame(m, g2m.material) + assertSame(m, g1m.material) + g1m.forEach { assertSame(m, it.material) } + + assertNotSame(m, g2.material) + assertNotSame(m, g2p.material) + assertNotSame(m, g1g.material) + assertNotSame(m, g1p.material) + + // Make sure the transformations on the shapes have not changed. + g1.zip(g1g).forEach { (s1, s2) -> assertSame(s1.transformation, s2.transformation) } + g1.zip(g1p).forEach { (s1, s2) -> assertSame(s1.transformation, s2.transformation) } + g1.zip(g1m).forEach { (s1, s2) -> assertSame(s1.transformation, s2.transformation) } + + // Make sure the transformations on the groups have / have not changed. + assertSame(g1.transformation, g1g.transformation) + assertSame(g1.transformation, g1p.transformation) + assertSame(g1.transformation, g1m.transformation) + assertNotSame(g2.transformation, g2p.transformation) + assertSame(g2m.transformation, g2p.transformation) + } +} diff --git a/src/test/kotlin/shapes/planetest.kt b/src/test/kotlin/shapes/PlaneTest.kt similarity index 100% rename from src/test/kotlin/shapes/planetest.kt rename to src/test/kotlin/shapes/PlaneTest.kt diff --git a/src/test/kotlin/shapes/ShapeTest.kt b/src/test/kotlin/shapes/ShapeTest.kt new file mode 100644 index 0000000..82daeeb --- /dev/null +++ b/src/test/kotlin/shapes/ShapeTest.kt @@ -0,0 +1,118 @@ +package shapes + +// By Sebastian Raaphorst, 2023. + +import material.Material +import math.* +import org.junit.jupiter.api.Test +import kotlin.math.PI +import kotlin.math.sqrt + +class ShapeTest { + class TestShape(transformation: Matrix = Matrix.I, + material: Material = Material(), + parent: Shape? = null): + Shape(transformation, material, true, parent) { + // We need to use a var here to store a ray. + var savedRay = Ray(Tuple.PZERO, Tuple.VZERO) + + override fun withParent(parent: Shape?): Shape { + val s = TestShape(transformation, material, parent) + s.savedRay = savedRay + return s + } + + override fun withMaterial(material: Material): Shape { + val s = TestShape(transformation, material, parent) + s.savedRay = savedRay + return s + } + + override fun localIntersect(rayLocal: Ray): List { + savedRay = rayLocal + return emptyList() + } + + override fun localNormalAt(localPoint: Tuple): Tuple = + localPoint - Tuple.PZERO + + override val bounds: BoundingBox by lazy { + BoundingBox( + Tuple.point(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY), + Tuple.point(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY) + ) + } + } + + @Test + fun `Intersecting scaled shape with ray`() { + val r = Ray(Tuple.point(0, 0, -5), Tuple.VZ) + val t = Matrix.scale(2, 2, 2) + val s = TestShape(t) + s.intersect(r) + assertAlmostEquals(Tuple.point(0, 0, -2.5), s.savedRay.origin) + assertAlmostEquals(Tuple.vector(0, 0, 0.5), s.savedRay.direction) + } + + @Test + fun `Intersecting translated shape with ray`() { + val r = Ray(Tuple.point(0, 0, -5), Tuple.VZ) + val t = Matrix.translate(5, 0, 0) + val s = TestShape(t) + s.intersect(r) + assertAlmostEquals(Tuple.point(-5, 0, -5), s.savedRay.origin) + assertAlmostEquals(Tuple.VZ, s.savedRay.direction) + } + + @Test + fun `Normal on translated shape`() { + val t = Matrix.translate(0, 1, 0) + val s = TestShape(t) + val n = s.normalAt(Tuple.point(0, 1.70711, -0.70711)) + assertAlmostEquals(Tuple.vector(0, 0.70711, -0.70711), n) + } + + @Test + fun `Normal on transformed shape`() { + val t = Matrix.scale(1, 0.5, 1) * Matrix.rotateZ(PI/5) + val s = TestShape(t) + val n = s.normalAt(Tuple.point(0, sqrt2by2, -sqrt2by2)) + assertAlmostEquals(Tuple.vector(0, 0.97014, -0.24254), n) + } + + @Test + fun `Point from world to local space`() { + val s = Sphere(Matrix.translate(5, 0, 0)) + val g2 = Group(Matrix.scale(2, 2, 2), children = listOf(s)) + val g1 = Group(Matrix.rotateY(PI / 2), children = listOf(g2)) + + // We have to get the new sphere to have the parent set. + val sNew = (g1.children[0] as Group).children[0] + val p = sNew.worldToLocal(Tuple.point(-2, 0, -10)) + assertAlmostEquals(Tuple.point(0, 0, -1), p) + } + + @Test + fun `Normal from local to world space`() { + val s = Sphere(Matrix.translate(5, 0, 0)) + val g2 = Group(Matrix.scale(1, 2, 3), children = listOf(s)) + val g1 = Group(Matrix.rotateY(PI / 2), children = listOf(g2)) + + // We have to get the new sphere to have the parent set. + val sNew = (g1.children[0] as Group).children[0] + val sqrt3by3 = sqrt(3.0) / 3 + val n = sNew.normalToWorld(Tuple.vector(sqrt3by3, sqrt3by3, sqrt3by3)) + assertAlmostEquals(Tuple.vector(0.28571, 0.42857, -0.85714), n) + } + + @Test + fun `Normal on an object in group`() { + val s = Sphere(Matrix.translate(5, 0, 0)) + val g2 = Group(Matrix.scale(1, 2, 3), children = listOf(s)) + val g1 = Group(Matrix.rotateY(PI / 2), children = listOf(g2)) + + val sp = (g1.children[0] as Group).children[0] + val n = sp.normalAt(Tuple.point(1.7321, 1.1547, -5.5774)) + assertAlmostEquals(Tuple.vector(0.28570, 0.42854, -0.85716), n) + } +} diff --git a/src/test/kotlin/shapes/spheretest.kt b/src/test/kotlin/shapes/SphereTest.kt similarity index 100% rename from src/test/kotlin/shapes/spheretest.kt rename to src/test/kotlin/shapes/SphereTest.kt diff --git a/src/test/kotlin/shapes/shapetest.kt b/src/test/kotlin/shapes/shapetest.kt deleted file mode 100644 index 458229b..0000000 --- a/src/test/kotlin/shapes/shapetest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package shapes - -// By Sebastian Raaphorst, 2023. - -import material.Material -import math.* -import org.junit.jupiter.api.Test -import kotlin.math.PI - -class ShapeTest { - class TestShape(transformation: Matrix = Matrix.I, - material: Material = Material()): Shape(transformation, material) { - // We need to use a var here to store a ray. - var savedRay = Ray(Tuple.PZERO, Tuple.VZERO) - - override fun localIntersect(rayLocal: Ray): List { - savedRay = rayLocal - return emptyList() - } - - override fun localNormalAt(localPoint: Tuple): Tuple = - localPoint - Tuple.PZERO - } - - @Test - fun `Intersecting scaled shape with ray`() { - val r = Ray(Tuple.point(0, 0, -5), Tuple.VZ) - val t = Matrix.scale(2, 2, 2) - val s = TestShape(t) - s.intersect(r) - assertAlmostEquals(Tuple.point(0, 0, -2.5), s.savedRay.origin) - assertAlmostEquals(Tuple.vector(0, 0, 0.5), s.savedRay.direction) - } - - @Test - fun `Intersecting translated shape with ray`() { - val r = Ray(Tuple.point(0, 0, -5), Tuple.VZ) - val t = Matrix.translate(5, 0, 0) - val s = TestShape(t) - s.intersect(r) - assertAlmostEquals(Tuple.point(-5, 0, -5), s.savedRay.origin) - assertAlmostEquals(Tuple.VZ, s.savedRay.direction) - } - - @Test - fun `Normal on translated shape`() { - val t = Matrix.translate(0, 1, 0) - val s = TestShape(t) - val n = s.normalAt(Tuple.point(0, 1.70711, -0.70711)) - assertAlmostEquals(Tuple.vector(0, 0.70711, -0.70711), n) - } - - @Test - fun `Normal on transformed shape`() { - val t = Matrix.scale(1, 0.5, 1) * Matrix.rotateZ(PI/5) - val s = TestShape(t) - val n = s.normalAt(Tuple.point(0, sqrt2by2, -sqrt2by2)) - assertAlmostEquals(Tuple.vector(0, 0.97014, -0.24254), n) - } -}