diff --git a/README.md b/README.md index bebaea3..fab7418 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,18 @@ class Object { reflectivity?: number = 0; }; +/** The specific values necessary for Planes. */ +class Plane extends Object { + /** Defines this object as a Plane. */ + type: string = "plane"; + + /** Any position on the plane. */ + position: Position = [0, 0, 0]; + + /** The direction the plane faces. */ + normal: Direction; +}; + /** The specific values necessary for Circles. */ class Circle extends Object { /** Defines this object as a Circle. */ @@ -172,18 +184,6 @@ class Circle extends Object { radius: number; }; -/** The specific values necessary for Planes. */ -class Plane extends Object { - /** Defines this object as a Plane. */ - type: string = "plane"; - - /** Any position on the plane. */ - position: Position = [0, 0, 0]; - - /** The direction the plane faces. */ - normal: Direction; -}; - /** The specific values necessary for Polygons. */ class Polygon extends Object { /** Defines this object as a Polygon. */ @@ -193,18 +193,6 @@ class Polygon extends Object { vertices: Array; }; -/** The specific values necessary for Spheres. */ -class Sphere extends Object { - /** Defines this object as a Sphere. */ - type: string = "sphere"; - - /** The center of the Sphere. */ - position: Position; - - /** The radius of the Sphere. Must be greater than 0. */ - radius: number; -}; - /** * The specific values necessary for Triangles. * The algorithm for Triangle intersections is slightly faster than Polygons, @@ -217,6 +205,18 @@ class Triangle extends Polygon { /** The number of vertices must be ONLY 3. */ }; +/** The specific values necessary for Spheres. */ +class Sphere extends Object { + /** Defines this object as a Sphere. */ + type: string = "sphere"; + + /** The center of the Sphere. */ + position: Position; + + /** The radius of the Sphere. Must be greater than 0. */ + radius: number; +}; + // More types of objects can be added later. // In the meantime, most kinds of objects can be modeled with Polygons. ``` @@ -234,3 +234,5 @@ To run the type-checker, use ```sh mypy . ``` + +The linter and type-checker will run automatically on pull requests, and success is required to merge. diff --git a/ruff.toml b/ruff.toml index cb99d66..da84663 100644 --- a/ruff.toml +++ b/ruff.toml @@ -8,6 +8,7 @@ ignore = [ "D203", # Conflicts with D211 - https://docs.astral.sh/ruff/rules/one-blank-line-before-class/ "D206", # Using tabs - https://docs.astral.sh/ruff/rules/indent-with-spaces/ "D212", # Conflicts with D213 - https://docs.astral.sh/ruff/rules/multi-line-summary-first-line/ + "E501", # Makes code messy - https://docs.astral.sh/ruff/rules/line-too-long/ "EM101", # Convenience - https://docs.astral.sh/ruff/rules/raw-string-in-exception/ "EM102", # Convenience - https://docs.astral.sh/ruff/rules/f-string-in-exception/ "ERA001", # False positives - https://docs.astral.sh/ruff/rules/commented-out-code/ diff --git a/src/__main__.py b/src/__main__.py index 5913a3f..0aa2210 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -33,16 +33,11 @@ def parse_arguments() -> tuple[str, str, int, int, int, bool]: # The ArgumentParser will take care of parsing them.) load_dotenv() env_scene = getenv("scene") - env_output = getenv("output", - default=DEFAULT_OUTPUT) - env_width = getenv("width", - default=str(DEFAULT_WIDTH)) - env_height = getenv("height", - default=str(DEFAULT_HEIGHT)) - env_reflection_limit = getenv("reflection-limit", - default=str(DEFAULT_REFLECTION_LIMIT)) - env_progress_bar = getenv("progress-bar", - default=str(DEFAULT_PROGRESS_BAR)) + env_output = getenv("output", default=DEFAULT_OUTPUT) + env_width = getenv("width", default=str(DEFAULT_WIDTH)) + env_height = getenv("height", default=str(DEFAULT_HEIGHT)) + env_reflection_limit = getenv("reflection-limit", default=str(DEFAULT_REFLECTION_LIMIT)) + env_progress_bar = getenv("progress-bar", default=str(DEFAULT_PROGRESS_BAR)) # Retrieve arguments overrides from command line # (A command line argument is only required if the environment variable is missing.) @@ -75,12 +70,10 @@ def parse_arguments() -> tuple[str, str, int, int, int, bool]: reflection_limit: int = parsed.reflection_limit progress_bar: bool = parsed.progress_bar - return scene_file_path, output_file_path, width, height, \ - reflection_limit, progress_bar + return scene_file_path, output_file_path, width, height, reflection_limit, progress_bar -def main(scene_file_path: str, output_file_path: str, width: int, height: int, - reflection_limit: int, progress_bar: bool) -> None: +def main(scene_file_path: str, output_file_path: str, width: int, height: int, reflection_limit: int, progress_bar: bool) -> None: """Import, ray-trace, and export.""" # Assert the output file extension is supported assert_supported_extension(output_file_path) diff --git a/src/importer.py b/src/importer.py index 974f2d2..15368d7 100644 --- a/src/importer.py +++ b/src/importer.py @@ -49,47 +49,66 @@ def _load_from_json(json: dict) -> Scene: error_prefix = "Scene" # Camera - camera_look_at = _validate_position_vector(json.get("camera_look_at"), - default=[0,0,0], - error_prefix=f"{error_prefix}.camera_look_at") - camera_look_from = _validate_position_vector(json.get("camera_look_from"), - default=[0,0,1], - error_prefix=f"{error_prefix}.camera_look_from") - camera_look_up = _validate_direction_vector(json.get("camera_look_up"), - default=[0,1,0], - error_prefix=f"{error_prefix}.camera_look_up") - field_of_view = _validate_number(json.get("field_of_view"), _min=0, _max=359, - default=90, - error_prefix=f"{error_prefix}.field_of_view") + camera_look_at = _validate_position_vector( + json.get("camera_look_at"), + default=[0,0,0], + error_prefix=f"{error_prefix}.camera_look_at", + ) + camera_look_from = _validate_position_vector( + json.get("camera_look_from"), + default=[0,0,1], + error_prefix=f"{error_prefix}.camera_look_from", + ) + camera_look_up = _validate_direction_vector( + json.get("camera_look_up"), + default=[0,1,0], + error_prefix=f"{error_prefix}.camera_look_up", + ) + field_of_view = _validate_number( + json.get("field_of_view"), _min=0, _max=359, + default=90, + error_prefix=f"{error_prefix}.field_of_view", + ) camera = Camera(camera_look_at, camera_look_from, camera_look_up, field_of_view) # Lighting - light_direction = _validate_direction_vector(json.get("light_direction"), - default=[0,1,0], - error_prefix=f"{error_prefix}.light_direction") - light_color = _validate_color_vector(json.get("light_color"), - default=[1,1,1], - error_prefix=f"{error_prefix}.light_color") - ambient_light_color = _validate_color_vector(json.get("ambient_light_color"), - default=[1,1,1], - error_prefix=f"{error_prefix}.ambient_light_color") - background_color = _validate_color_vector(json.get("background_color"), - default=[0,0,0], - error_prefix=f"{error_prefix}.background_color") + light_direction = _validate_direction_vector( + json.get("light_direction"), + default=[0,1,0], + error_prefix=f"{error_prefix}.light_direction", + ) + light_color = _validate_color_vector( + json.get("light_color"), + default=[1,1,1], + error_prefix=f"{error_prefix}.light_color", + ) + ambient_light_color = _validate_color_vector( + json.get("ambient_light_color"), + default=[1,1,1], + error_prefix=f"{error_prefix}.ambient_light_color", + ) + background_color = _validate_color_vector( + json.get("background_color"), + default=[0,0,0], + error_prefix=f"{error_prefix}.background_color", + ) # Objects - objects = _load_objects(json.get("objects"), - default=[], - error_prefix=f"{error_prefix}.objects") + objects = _load_objects( + json.get("objects"), + default=[], + error_prefix=f"{error_prefix}.objects", + ) - return Scene(camera, light_direction, light_color, ambient_light_color, - background_color, objects) + return Scene(camera, light_direction, light_color, ambient_light_color, background_color, objects) -def _load_objects(json_value: Any | None, +def _load_objects( + json_value: Any | None, default: list[Object] | None = None, - error_prefix: str = "Objects") -> list[Object]: + error_prefix: str = "Objects", +) -> list[Object]: """Take a list of dictionaries and imports each of them as an Object.""" objects = [] @@ -114,8 +133,7 @@ def _load_object(json_value: Any | None, error_prefix: str = "Object") -> Object if obj_type is None: raise ValueError(f"{error_prefix}.type must not be missing") if not isinstance(obj_type, str): - raise TypeError(f"{error_prefix}.type must be type string, \ - not {type(obj_type)}") + raise TypeError(f"{error_prefix}.type must be type string, not {type(obj_type)}") obj_type = obj_type.lower() # Load in object-type-specific values @@ -138,122 +156,179 @@ def _load_object(json_value: Any | None, error_prefix: str = "Object") -> Object raise TypeError(f"{error_prefix}.name must be type string, not {type(name)}") obj.name = name - obj.ambient_coefficient = _validate_number(json_value.get("ambient_coefficient"), - default=0, - error_prefix=f"{error_prefix}.ambient_coefficient") - obj.diffuse_coefficient = _validate_number(json_value.get("diffuse_coefficient"), - default=1, - error_prefix=f"{error_prefix}.diffuse_coefficient") - obj.specular_coefficient = _validate_number(json_value.get("specular_coefficient"), - default=0, - error_prefix=f"{error_prefix}.specular_coefficient") - obj.diffuse_color = _validate_color_vector(json_value.get("diffuse_color"), - default=[1,1,1], - error_prefix=f"{error_prefix}.diffuse_color") - obj.specular_color = _validate_color_vector(json_value.get("specular_color"), - default=[1,1,1], - error_prefix=f"{error_prefix}.specular_color") - obj.gloss_coefficient = _validate_number(json_value.get("gloss_coefficient"), - default=4, - error_prefix=f"{error_prefix}.gloss_coefficient") - obj.reflectivity = _validate_number(json_value.get("reflectivity"), - default=0, - error_prefix=f"{error_prefix}.reflectivity") + obj.ambient_coefficient = _validate_number( + json_value.get("ambient_coefficient"), + default=0, + error_prefix=f"{error_prefix}.ambient_coefficient", + ) + obj.diffuse_coefficient = _validate_number( + json_value.get("diffuse_coefficient"), + default=1, + error_prefix=f"{error_prefix}.diffuse_coefficient", + ) + obj.specular_coefficient = _validate_number( + json_value.get("specular_coefficient"), + default=0, + error_prefix=f"{error_prefix}.specular_coefficient", + ) + obj.diffuse_color = _validate_color_vector( + json_value.get("diffuse_color"), + default=[1,1,1], + error_prefix=f"{error_prefix}.diffuse_color", + ) + obj.specular_color = _validate_color_vector( + json_value.get("specular_color"), + default=[1,1,1], + error_prefix=f"{error_prefix}.specular_color", + ) + obj.gloss_coefficient = _validate_number( + json_value.get("gloss_coefficient"), + default=4, + error_prefix=f"{error_prefix}.gloss_coefficient", + ) + obj.reflectivity = _validate_number( + json_value.get("reflectivity"), + default=0, + error_prefix=f"{error_prefix}.reflectivity", + ) return obj -def _load_circle(json_value: dict, error_prefix: str = "Circle") -> Circle: - """Import Circle-specific values from a dictionary.""" - position = _validate_position_vector(json_value.get("position"), - error_prefix=f"{error_prefix}.position") - normal = _validate_direction_vector(json_value.get("normal"), default=[0,0,1], - error_prefix=f"{error_prefix}.normal") - radius = _validate_number(json_value.get("radius"), _min=0, - error_prefix=f"{error_prefix}.radius") - - return Circle(position, normal, radius) - - def _load_plane(json_value: dict, error_prefix: str = "Plane") -> Plane: """Import Plane-specific values from a dictionary.""" - position = _validate_position_vector(json_value.get("position"), default=[0,0,0], - error_prefix=f"{error_prefix}.position") - normal = _validate_direction_vector(json_value.get("normal"), - error_prefix=f"{error_prefix}.normal") + position = _validate_position_vector( + json_value.get("position"), + default=[0,0,0], + error_prefix=f"{error_prefix}.position", + ) + normal = _validate_direction_vector( + json_value.get("normal"), + error_prefix=f"{error_prefix}.normal", + ) return Plane(position, normal) +def _load_circle(json_value: dict, error_prefix: str = "Circle") -> Circle: + """Import Circle-specific values from a dictionary.""" + position = _validate_position_vector( + json_value.get("position"), + error_prefix=f"{error_prefix}.position", + ) + normal = _validate_direction_vector( + json_value.get("normal"), + default=[0,0,1], + error_prefix=f"{error_prefix}.normal", + ) + radius = _validate_number( + json_value.get("radius"), + _min=0, + error_prefix=f"{error_prefix}.radius", + ) + + return Circle(position, normal, radius) + + def _load_polygon(json_value: dict, error_prefix: str = "Polygon") -> Polygon: """Import Polygon-specific values from a dictionary.""" vertices_numpyified = [] - vertices = _validate_list(json_value.get("vertices"), - error_prefix=f"{error_prefix}.vertices") + vertices = _validate_list( + json_value.get("vertices"), + error_prefix=f"{error_prefix}.vertices", + ) if len(vertices) < Polygon.MIN_VERTICES: raise ValueError(f"{error_prefix}.vertices must have at least 3 vertices") if len(vertices) == Triangle.REQUIRED_VERTICES: - print(f"WARNING: {error_prefix} only has 3 vertices, \ - automatically converting to Triangle") + print(f"WARNING: {error_prefix} only has 3 vertices, automatically converting to Triangle") return _load_triangle(json_value, error_prefix) for count, element in enumerate(vertices): - vertex = _validate_position_vector(element, - error_prefix=f"{error_prefix}.vertices[{count}]") + vertex = _validate_position_vector( + element, + error_prefix=f"{error_prefix}.vertices[{count}]", + ) vertices_numpyified.append(vertex) return Polygon(vertices_numpyified) -def _load_sphere(json_value: dict, error_prefix: str = "Sphere") -> Sphere: - """Import Sphere-specific values from a dictionary.""" - position = _validate_position_vector(json_value.get("position"), - error_prefix=f"{error_prefix}.position") - radius = _validate_number(json_value.get("radius"), _min=0, - error_prefix=f"{error_prefix}.radius") - - return Sphere(position, radius) - - def _load_triangle(json_value: dict, error_prefix: str = "Triangle") -> Triangle: """Import Triangle-specific values from a dictionary.""" vertices_numpyified = [] - vertices = _validate_list(json_value.get("vertices"), - error_prefix=f"{error_prefix}.vertices") + vertices = _validate_list( + json_value.get("vertices"), + error_prefix=f"{error_prefix}.vertices", + ) if len(vertices) != Triangle.REQUIRED_VERTICES: raise ValueError(f"{error_prefix}.vertices must have 3 vertices") for count, element in enumerate(vertices): - vertex = _validate_position_vector(element, - error_prefix=f"{error_prefix}.vertices[{count}]") + vertex = _validate_position_vector( + element, + error_prefix=f"{error_prefix}.vertices[{count}]", + ) vertices_numpyified.append(vertex) return Triangle(vertices_numpyified) -def _validate_position_vector(json_value: Any | None, - default: list[float] | None = None, - error_prefix: str = "Position") -> NDArray[np.float64]: +def _load_sphere(json_value: dict, error_prefix: str = "Sphere") -> Sphere: + """Import Sphere-specific values from a dictionary.""" + position = _validate_position_vector( + json_value.get("position"), + error_prefix=f"{error_prefix}.position", + ) + radius = _validate_number( + json_value.get("radius"), + _min=0, + error_prefix=f"{error_prefix}.radius", + ) + + return Sphere(position, radius) + + +def _validate_position_vector( + json_value: Any | None, + default: list[float] | None = None, + error_prefix: str = "Position", +) -> NDArray[np.float64]: """Import a list as a position vector.""" - json_value = _validate_list(json_value, length=3, default=default, - error_prefix=error_prefix) + json_value = _validate_list( + json_value, + length=3, + default=default, + error_prefix=error_prefix, + ) for element in json_value: - _validate_number(element, error_prefix=f"{error_prefix} element") + _validate_number( + element, + error_prefix=f"{error_prefix} element", + ) return np.array(json_value) -def _validate_direction_vector(json_value: Any | None, - default: list[float] | None = None, - error_prefix: str = "Direction") -> NDArray[np.float64]: +def _validate_direction_vector( + json_value: Any | None, + default: list[float] | None = None, + error_prefix: str = "Direction", +) -> NDArray[np.float64]: """Import a list as a direction vector, verifying it is normalized.""" - json_value = _validate_list(json_value, length=3, default=default, - error_prefix=error_prefix) + json_value = _validate_list( + json_value, + length=3, + default=default, + error_prefix=error_prefix, + ) for element in json_value: - _validate_number(element, error_prefix=f"{error_prefix} element") + _validate_number( + element, + error_prefix=f"{error_prefix} element", + ) # Convert to numpy array vector = np.array(json_value) @@ -261,36 +336,45 @@ def _validate_direction_vector(json_value: Any | None, # Make sure the vector is normalized mag = magnitude(vector) if mag not in (0, 1): - print(f"WARNING: {error_prefix} is not normalized, \ - performing auto-normalization") + print(f"WARNING: {error_prefix} is not normalized, performing auto-normalization") vector = normalized(vector) - print(f"WARNING: {error_prefix} has been normalized to \ - [{vector[0]}, {vector[1]}, {vector[2]}]") + print(f"WARNING: {error_prefix} has been normalized to [{vector[0]}, {vector[1]}, {vector[2]}]") return vector -def _validate_color_vector(json_value: Any | None, - default: list[float] | None = None, - error_prefix: str = "Color") -> NDArray[np.float64]: +def _validate_color_vector( + json_value: Any | None, + default: list[float] | None = None, + error_prefix: str = "Color", +) -> NDArray[np.float64]: """Import a list as a color vector, verifying the proper range of values.""" - json_value = _validate_list(json_value, length=3, default=default, - error_prefix=error_prefix) + json_value = _validate_list( + json_value, + length=3, + default=default, + error_prefix=error_prefix, + ) for element in json_value: - _validate_number(element, _min=0, _max=1, - error_prefix=f"{error_prefix} element") + _validate_number( + element, + _min=0, + _max=1, + error_prefix=f"{error_prefix} element", + ) return np.array(json_value) -def _validate_list(json_value: Any | None, - length: int | None = None, - default: list | None = None, - error_prefix: str = "List") -> list: +def _validate_list( + json_value: Any | None, + length: int | None = None, + default: list | None = None, + error_prefix: str = "List", +) -> list: """Import a list and validates it against certain constraints.""" if json_value is None and default is not None: - print(f"WARNING: {error_prefix} is missing, \ - reverting to default value {default}") + print(f"WARNING: {error_prefix} is missing, reverting to default value {default}") return default if json_value is None: @@ -298,31 +382,29 @@ def _validate_list(json_value: Any | None, if not isinstance(json_value, list): raise TypeError(f"{error_prefix} must be type list, not {type(json_value)}") if length is not None and len(json_value) != length: - raise ValueError(f"{error_prefix} must have {length} elements, \ - not {len(json_value)}") + raise ValueError(f"{error_prefix} must have {length} elements, not {len(json_value)}") return json_value -def _validate_number(json_value: Any | None, - _min: float | None = None, - _max: float | None = None, - default: float | None = None, - error_prefix: str = "Number") -> float | int: +def _validate_number( + json_value: Any | None, + _min: float | None = None, + _max: float | None = None, + default: float | None = None, + error_prefix: str = "Number", +) -> float | int: """Import a number and validates it against certain constraints.""" if json_value is None and default is not None: - print(f"WARNING: {error_prefix} is missing, \ - reverting to default value {default}") + print(f"WARNING: {error_prefix} is missing, reverting to default value {default}") return default if json_value is None: raise ValueError(f"{error_prefix} must not be missing") if not isinstance(json_value, float | int): - raise TypeError(f"{error_prefix} must be type float or int, \ - not {type(json_value)}") + raise TypeError(f"{error_prefix} must be type float or int, not {type(json_value)}") if _min is not None and json_value < _min: - raise ValueError(f"{error_prefix} must be greater than {_min}, \ - not {json_value}") + raise ValueError(f"{error_prefix} must be greater than {_min}, not {json_value}") if _max is not None and json_value > _max: raise ValueError(f"{error_prefix} must be less than {_max}, not {json_value}") diff --git a/src/objects.py b/src/objects.py index 04ab8e5..6b03a33 100644 --- a/src/objects.py +++ b/src/objects.py @@ -39,8 +39,7 @@ class Plane(Object): _normal: NDArray[np.float64] _distance_from_origin: float - def __init__(self, position: NDArray[np.float64], normal: NDArray[np.float64], - ) -> None: + def __init__(self, position: NDArray[np.float64], normal: NDArray[np.float64]) -> None: """Initialize an instance of Plane.""" super().__init__() @@ -54,14 +53,12 @@ def normal(self, point: NDArray[np.float64] | None = None) -> NDArray[np.float64 def ray_intersection(self, ray: Ray) -> RayCollision | None: """Calculate whether the given ray collides with this object.""" v_d = np.dot(self._normal, ray.direction) - if v_d == 0: # Ray is parallel to plane return None - v_o = -1 * (np.dot(self._normal, ray.origin) + self._distance_from_origin) + v_o = -np.dot(self._normal, ray.origin) - self._distance_from_origin t = v_o / v_d - if t <= 0: # Intersection point is behind the ray return None @@ -76,8 +73,7 @@ class Circle(Object): radius: float _plane: Plane - def __init__(self, position: NDArray[np.float64], normal: NDArray[np.float64], - radius: float) -> None: + def __init__(self, position: NDArray[np.float64], normal: NDArray[np.float64], radius: float) -> None: """Initialize an instance of Circle.""" super().__init__() @@ -134,15 +130,13 @@ def __init__(self, vertices: list[NDArray[np.float64]]) -> None: vector_2 = normalized(vertices[2] - vertices[1]) _normal = normalized(np.cross(vector_1, vector_2)) - - self._plane = Plane(vertices[0], _normal) + self._plane = Plane(self._vertices[0], _normal) # Project all the vertices onto a 2D plane for future intersection calculations self._plane_dominant_coord: int = np.where( np.abs(_normal) == np.max(np.abs(_normal)), )[0][0] - self._flattened_vertices = [np.delete(v, self._plane_dominant_coord) - for v in self._vertices] + self._flattened_vertices = [np.delete(v, self._plane_dominant_coord) for v in self._vertices] def normal(self, point: NDArray[np.float64] | None = None) -> NDArray[np.float64]: """Return the "up" direction, which is the same for every point.""" @@ -199,49 +193,6 @@ def ray_intersection(self, ray: Ray) -> RayCollision | None: return RayCollision(self, ray, intersection) -class Sphere(Object): - """The specific values necessary for Spheres.""" - - position: NDArray[np.float64] - radius: float - - def __init__(self, position: NDArray[np.float64], radius: float) -> None: - """Initialize an instance of Sphere.""" - super().__init__() - self.position = position - self.radius = radius - - def normal(self, point: NDArray[np.float64]) -> NDArray[np.float64]: - """Return the "up" direction from the point on the object.""" - return normalized(point - self.position) - - def ray_intersection(self, ray: Ray) -> RayCollision | None: - """Calculate whether the given ray collides with this object.""" - dist = self.position - ray.origin - dist_sqr = np.dot(dist, dist) - dist_mag = np.sqrt(dist_sqr) - - outside = dist_mag >= self.radius - - closest_approach = np.dot(ray.direction, dist) - - if closest_approach < 0 and outside: - return None - - closest_approach_dist_to_surface_sqr = \ - self.radius**2 - dist_sqr + closest_approach**2 - - if closest_approach_dist_to_surface_sqr < 0: - return None - - closest_approach_dist_to_surface = closest_approach_dist_to_surface_sqr**0.5 - - t = closest_approach - closest_approach_dist_to_surface if outside \ - else closest_approach + closest_approach_dist_to_surface - - return RayCollision(self, ray, ray.origin + ray.direction*t) - - class Triangle(Polygon): """ The specific values necessary for Triangles. @@ -262,8 +213,7 @@ def __init__(self, vertices: list[NDArray[np.float64]]) -> None: super().__init__(vertices) if len(vertices) != Triangle.REQUIRED_VERTICES: - raise ValueError(f"Triangle must have \ - {Triangle.REQUIRED_VERTICES} vertices") + raise ValueError(f"Triangle must have {Triangle.REQUIRED_VERTICES} vertices") self._flattened_area = Triangle.area(self._flattened_vertices) @@ -313,3 +263,45 @@ def area(vertices: list[NDArray[np.float64]]) -> float: + vertices[1][0] * (vertices[2][1] - vertices[0][1]) \ + vertices[2][0] * (vertices[0][1] - vertices[1][1])) / 2.0 return abs(area) + + +class Sphere(Object): + """The specific values necessary for Spheres.""" + + position: NDArray[np.float64] + radius: float + + def __init__(self, position: NDArray[np.float64], radius: float) -> None: + """Initialize an instance of Sphere.""" + super().__init__() + self.position = position + self.radius = radius + + def normal(self, point: NDArray[np.float64]) -> NDArray[np.float64]: + """Return the "up" direction from the point on the object.""" + return normalized(point - self.position) + + def ray_intersection(self, ray: Ray) -> RayCollision | None: + """Calculate whether the given ray collides with this object.""" + relative_position = self.position - ray.origin + distance_sqr = np.dot(relative_position, relative_position) + distance = distance_sqr**0.5 + + origin_outside = distance >= self.radius + + closest_approach = np.dot(ray.direction, relative_position) + + if closest_approach < 0 and origin_outside: + return None + + closest_approach_dist_to_surface_sqr = self.radius**2 - distance_sqr + closest_approach**2 + + if closest_approach_dist_to_surface_sqr < 0: + return None + + closest_approach_dist_to_surface = closest_approach_dist_to_surface_sqr**0.5 + + t = (closest_approach - closest_approach_dist_to_surface) if origin_outside \ + else (closest_approach + closest_approach_dist_to_surface) + + return RayCollision(self, ray, ray.origin + ray.direction*t) diff --git a/src/ray.py b/src/ray.py index d6ec967..5b461ef 100644 --- a/src/ray.py +++ b/src/ray.py @@ -12,8 +12,7 @@ class Ray: origin: NDArray[np.float64] direction: NDArray[np.float64] - def __init__(self, origin: NDArray[np.float64], direction: NDArray[np.float64], - ) -> None: + def __init__(self, origin: NDArray[np.float64], direction: NDArray[np.float64]) -> None: """Initialize an instance of Ray.""" self.origin = origin self.direction = direction @@ -27,7 +26,8 @@ class RayCollision: position: NDArray[np.float64] distance: float - def __init__(self, obj, ray: Ray, position: NDArray[np.float64]) -> None: # noqa: ANN001 + # obj: Object would cause a circular import + def __init__(self, obj, ray: Ray, position: NDArray[np.float64]) -> None: # noqa: ANN001 """Initialize an instance of RayCollision.""" self.obj = obj self.ray = ray diff --git a/src/ray_tracer.py b/src/ray_tracer.py index 3b9c86a..2799d31 100644 --- a/src/ray_tracer.py +++ b/src/ray_tracer.py @@ -23,8 +23,7 @@ "Offsets collision positions from the surfaces of objects to avoid incorrect shadows" -def ray_trace(scene: Scene, width: int, height: int, - reflection_limit: int, progress_bar: bool) -> NDArray[np.float64]: +def ray_trace(scene: Scene, width: int, height: int, reflection_limit: int, progress_bar: bool) -> NDArray[np.float64]: """ Ray traces the given scene. @@ -32,24 +31,22 @@ def ray_trace(scene: Scene, width: int, height: int, """ # Save time by pre-calcuating constant values viewport_size = np.array([width, height]) - window_size = _get_window_size(viewport_size, - scene.camera.focal_length, scene.camera.field_of_view) + window_size = _get_window_size(viewport_size, scene.camera.focal_length, scene.camera.field_of_view) window_to_viewport_size_ratio = window_size/viewport_size half_window_size = window_size/2 # Set up multiprocessing pool and inputs - tuple_inputs = [ - ( - scene, reflection_limit, x, y, - window_to_viewport_size_ratio, - half_window_size, - ) - for y in range(height) for x in range(width) - ] + tuple_inputs = [( + scene, + reflection_limit, + x, + y, + window_to_viewport_size_ratio, + half_window_size, + ) for y in range(height) for x in range(width)] outputs = [] with Pool(cpu_count()) as pool: - # Start a process for ray-tracing each pixel processes: Iterable = pool.imap(_ray_trace_pixel_tuple, tuple_inputs) if progress_bar: @@ -66,37 +63,49 @@ def ray_trace(scene: Scene, width: int, height: int, return screen -def _ray_trace_pixel_tuple( - tuple_input: tuple[ - Scene, int, int, int, - NDArray[np.float64], - NDArray[np.float64], - ], - ) -> NDArray[np.float64]: +def _ray_trace_pixel_tuple(tuple_input: tuple[ + Scene, + int, + int, + int, + NDArray[np.float64], + NDArray[np.float64], +]) -> NDArray[np.float64]: """Unpacks the tuple input for _ray_trace_pixel and returns the result.""" return _ray_trace_pixel(*tuple_input) def _ray_trace_pixel( - scene: Scene, reflection_limit: int, x: int, y: int, - window_to_viewport_size_ratio: NDArray[np.float64], - half_window_size: NDArray[np.float64], - ) -> NDArray[np.float64]: + scene: Scene, + reflection_limit: int, + x: int, + y: int, + window_to_viewport_size_ratio: NDArray[np.float64], + half_window_size: NDArray[np.float64], +) -> NDArray[np.float64]: """Retrieve the color for a given pixel.""" # Find the world point of the pixel, relative to the camera's position viewport_point = np.array([x, y]) - window_point = _viewport_to_window(viewport_point, window_to_viewport_size_ratio, - half_window_size) + window_point = _viewport_to_window(viewport_point, window_to_viewport_size_ratio, half_window_size) world_point_relative = _window_to_relative_world(window_point, scene.camera) # Start sending out rays - return _get_color(scene, reflection_limit, - scene.camera.position, normalized(world_point_relative)) - - -def _get_color(scene: Scene, reflection_limit: int, - origin: NDArray[np.float64], direction: NDArray[np.float64], - fade: float = 1.0, reflections: int = 0) -> NDArray[np.float64]: + return _get_color( + scene, + reflection_limit, + scene.camera.position, + normalized(world_point_relative), + ) + + +def _get_color( + scene: Scene, + reflection_limit: int, + origin: NDArray[np.float64], + direction: NDArray[np.float64], + fade: float = 1.0, + reflections: int = 0, +) -> NDArray[np.float64]: """Recursively cast rays to retrieve the color for the original ray collision.""" if fade <= FADE_LIMIT or reflections > reflection_limit: return np.array([0,0,0]) @@ -107,7 +116,6 @@ def _get_color(scene: Scene, reflection_limit: int, # Shade the pixel using the collided object if collision is not None: - # Shadows # Avoid getting trapped inside objects normal = collision.obj.normal(collision.position) @@ -115,16 +123,19 @@ def _get_color(scene: Scene, reflection_limit: int, shadow = _is_in_shadow(scene, collision.position) # Reflections (recursive) - sight_reflection_direction = \ - ray.direction - 2 * normal * np.dot(ray.direction, normal) - reflected_color = _get_color(scene, reflection_limit, collision.position, - sight_reflection_direction, fade*collision.obj.reflectivity, - reflections+1) + sight_reflection_direction = ray.direction - 2 * normal * np.dot(ray.direction, normal) + reflected_color = _get_color( + scene, + reflection_limit, + collision.position, + sight_reflection_direction, + fade=fade*collision.obj.reflectivity, + reflections=reflections+1, + ) # Shading view_direction = -1 * ray.direction - return shade(scene, collision.obj, collision.position, view_direction, shadow, - reflected_color) + return shade(scene, collision.obj, collision.position, view_direction, shadow, reflected_color) # If no object collided, use the background return scene.background_color @@ -137,17 +148,18 @@ def _is_in_shadow(scene: Scene, point: NDArray[np.float64]) -> bool: return collision is not None -def _get_window_size(viewport_size: NDArray[np.int64], focal_length: float, - field_of_view: float) -> NDArray[np.float64]: +def _get_window_size(viewport_size: NDArray[np.int64], focal_length: float, field_of_view: float) -> NDArray[np.float64]: """Return the window size, given the camera properties.""" x = focal_length * tan(np.deg2rad(field_of_view/2)) * 2 y = x * viewport_size[1]/viewport_size[0] return np.array([x, y]) -def _viewport_to_window(viewport_point: NDArray[np.float64], - window_to_viewport_size_ratio: NDArray[np.float64], - half_window_size: NDArray[np.float64]) -> NDArray[np.float64]: +def _viewport_to_window( + viewport_point: NDArray[np.float64], + window_to_viewport_size_ratio: NDArray[np.float64], + half_window_size: NDArray[np.float64], +) -> NDArray[np.float64]: """Convert a point on the viewport to a point on the window.""" window_point = viewport_point * window_to_viewport_size_ratio - half_window_size # The -1 seems necessary to orient it correctly @@ -155,8 +167,7 @@ def _viewport_to_window(viewport_point: NDArray[np.float64], return np.concatenate([window_point, [0]]) -def _window_to_relative_world(window_point: NDArray[np.float64], camera: Camera, - ) -> NDArray[np.float64]: +def _window_to_relative_world(window_point: NDArray[np.float64], camera: Camera) -> NDArray[np.float64]: """Convert a point on the window to world point (relative to the camera).""" axes = np.array([camera.right, camera.up, camera.forward]) return camera.relative_look_at + np.dot(window_point, axes) diff --git a/src/scene.py b/src/scene.py index 8de3bcb..38535f7 100644 --- a/src/scene.py +++ b/src/scene.py @@ -19,9 +19,13 @@ class Camera: up: NDArray[np.float64] right: NDArray[np.float64] - def __init__(self, camera_look_at: NDArray[np.float64], - camera_look_from: NDArray[np.float64], camera_look_up: NDArray[np.float64], - field_of_view: float) -> None: + def __init__( + self, + camera_look_at: NDArray[np.float64], + camera_look_from: NDArray[np.float64], + camera_look_up: NDArray[np.float64], + field_of_view: float, + ) -> None: """Initialize an instance of Camera.""" self.position = camera_look_from self.field_of_view = field_of_view @@ -44,9 +48,15 @@ class Scene: background_color: NDArray[np.float64] objects: list[Object] - def __init__(self, camera: Camera, light_direction: NDArray[np.float64], - light_color: NDArray[np.float64], ambient_light_color: NDArray[np.float64], - background_color: NDArray[np.float64], objects: list[Object]) -> None: + def __init__( + self, + camera: Camera, + light_direction: NDArray[np.float64], + light_color: NDArray[np.float64], + ambient_light_color: NDArray[np.float64], + background_color: NDArray[np.float64], + objects: list[Object], + ) -> None: """Initialize an instance of Scene.""" # Camera self.camera = camera diff --git a/src/shader.py b/src/shader.py index d134c4c..cd43d0b 100644 --- a/src/shader.py +++ b/src/shader.py @@ -7,9 +7,13 @@ from scene import Scene -def shade(scene: Scene, obj: Object, position: NDArray[np.float64], +def shade( + scene: Scene, + obj: Object, + position: NDArray[np.float64], view_direction: NDArray[np.float64], shadow: bool, - reflected_color: NDArray[np.float64]) -> NDArray[np.float64]: + reflected_color: NDArray[np.float64], +) -> NDArray[np.float64]: """ Apply [Phong shading](https://en.wikipedia.org/wiki/Phong_shading) to the object. @@ -19,8 +23,7 @@ def shade(scene: Scene, obj: Object, position: NDArray[np.float64], surface_normal = obj.normal(position) normal_dot_light = np.dot(surface_normal, scene.light_direction) - light_reflection_direction \ - = 2 * surface_normal * normal_dot_light - scene.light_direction + light_reflection_direction = 2 * surface_normal * normal_dot_light - scene.light_direction view_dot_light = np.dot(view_direction, light_reflection_direction) # Ambient lighting @@ -33,8 +36,7 @@ def shade(scene: Scene, obj: Object, position: NDArray[np.float64], diffuse *= shadow_coefficient # Specular lighting - specular = scene.light_color * obj.specular_color \ - * max(0, view_dot_light)**obj.gloss_coefficient + specular = scene.light_color * obj.specular_color * max(0, view_dot_light)**obj.gloss_coefficient specular *= obj.specular_coefficient specular *= shadow_coefficient diff --git a/src/vector.py b/src/vector.py index 55cc832..94ec22a 100644 --- a/src/vector.py +++ b/src/vector.py @@ -8,12 +8,11 @@ def magnitude(vector: NDArray) -> float: """Return the scalar length of the vector.""" if vector.ndim != 1: - raise ValueError(f"A vector must be 1-dimensional, \ - not {vector.ndim}-dimensional") + raise ValueError(f"A vector must be 1-dimensional, not {vector.ndim}-dimensional") return float(np.linalg.norm(vector)) def normalized(vector: NDArray) -> NDArray[np.float64]: - """Return a new vector pointing in the same direction with a length of 1 or 0.""" + """Return a new vector with the same direction but with a length of 1 or 0.""" mag = magnitude(vector) return vector / mag if mag != 0 else vector