From 34141e858cc06816cc0979db4ea13682a2a9cd00 Mon Sep 17 00:00:00 2001 From: "Sean P. Kelly" Date: Fri, 22 Nov 2024 18:45:34 -0800 Subject: [PATCH] fix: fix bug in quad collision detection Arbitrary quad collision function was using code that only works for rectangles. This refactors the code to perform intersection checks with the sum of two triangles. This also fixes a bug preventing pyraydeon shapes from using native geometry for collision detection. --- Makefile | 16 +- pyraydeon/check-examples.sh | 3 - pyraydeon/examples/py_cubes.py | 62 +++---- pyraydeon/examples/py_rhombohedron.py | 155 ++++++++++++++++++ .../examples/py_rhombohedron_expected.svg | 1 + pyraydeon/src/linear.rs | 39 +---- pyraydeon/src/ray.rs | 37 ++++- pyraydeon/src/shapes/mod.rs | 34 ++-- pyraydeon/src/shapes/primitive.rs | 71 ++++++++ raydeon/src/shapes/aacuboid.rs | 1 + raydeon/src/shapes/mod.rs | 2 + raydeon/src/shapes/plane.rs | 1 + raydeon/src/shapes/quad.rs | 70 ++++++++ raydeon/src/shapes/sphere.rs | 1 + raydeon/src/shapes/triangle.rs | 1 + 15 files changed, 407 insertions(+), 87 deletions(-) create mode 100644 pyraydeon/examples/py_rhombohedron.py create mode 100644 pyraydeon/examples/py_rhombohedron_expected.svg create mode 100644 raydeon/src/shapes/quad.rs diff --git a/Makefile b/Makefile index 88ed72a..3f58df0 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,23 @@ check-fmt: cargo fmt --check uv --project pyraydeon run ruff format --check pyraydeon -.PHONY: render-test -render-test: +.PHONY: rust-render-test +rust-render-test: ./raydeon/check-examples.sh + +.PHONY: reinstall-py-venv +reinstall-py-venv: + echo "Reinstalling native dependencies in virtualenv..." + uv --project pyraydeon run --reinstall python -c 'print("Reinstalled dependencies")' + + +.PHONY: py-render-test +py-render-test: reinstall-py-venv ./pyraydeon/check-examples.sh +.PHONY: render-test +render-test: rust-render-test py-render-test + .PHONY: unit-test unit-test: cargo test --locked diff --git a/pyraydeon/check-examples.sh b/pyraydeon/check-examples.sh index 814d906..e8462df 100755 --- a/pyraydeon/check-examples.sh +++ b/pyraydeon/check-examples.sh @@ -22,9 +22,6 @@ if [ "$ALL_TOOLS_FOUND" = false ]; then exit 1 fi -echo "Reinstalling native dependencies in virtualenv..." -uv --project ${SCRIPT_DIR} run --reinstall python -c 'print("Reinstalled dependencies")' - for example in ${SCRIPT_DIR}/examples/*.py; do example_name=$(basename "$example" .py) diff --git a/pyraydeon/examples/py_cubes.py b/pyraydeon/examples/py_cubes.py index 3f9d971..2d62a8d 100644 --- a/pyraydeon/examples/py_cubes.py +++ b/pyraydeon/examples/py_cubes.py @@ -19,36 +19,6 @@ ) -class Quad(CollisionGeometry): - def __init__(self, vertices): - self.vertices = vertices - self.plane = self.compute_plane(vertices) - - def compute_plane(self, points): - p1, p2, p3 = points[:3] - normal = np.cross(p2 - p1, p3 - p1) - normal /= np.linalg.norm(normal) - return Plane(p1, normal) - - def is_point_in_face(self, point): - edge1 = self.vertices[1] - self.vertices[0] - edge2 = self.vertices[3] - self.vertices[0] - v = point - self.vertices[0] - u1 = np.dot(v, edge1) / np.dot(edge1, edge1) - u2 = np.dot(v, edge2) / np.dot(edge2, edge2) - return 0 <= u1 <= 1 and 0 <= u2 <= 1 - - def hit_by(self, ray) -> HitData | None: - intersection = self.plane.hit_by(ray) - if intersection is not None and self.is_point_in_face(intersection.hit_point): - return intersection - - def bounding_box(self): - my_min = np.minimum.reduce(self.vertices) - my_max = np.maximum.reduce(self.vertices) - return AABB3(my_min, my_max) - - class RectPrism(Geometry): def __init__( self, @@ -117,7 +87,7 @@ def __repr__(self): return f"RectPrism(basis='[{self.right}, {self.up}, {self.fwd}]', dims='[{self.width}, {self.height}, {self.depth}]')" def collision_geometry(self): - return [Quad(self.vertices[face]) for face in self.faces] + return [PyQuad(self.vertices[face]) for face in self.faces] def paths(self, cam): edges = set( @@ -134,6 +104,36 @@ def paths(self, cam): return paths +class PyQuad(CollisionGeometry): + def __init__(self, vertices): + self.vertices = vertices + self.plane = self.compute_plane(vertices) + + def compute_plane(self, points): + p1, p2, p3 = points[:3] + normal = np.cross(p2 - p1, p3 - p1) + normal /= np.linalg.norm(normal) + return Plane(p1, normal) + + def is_point_in_face(self, point): + edge1 = self.vertices[1] - self.vertices[0] + edge2 = self.vertices[3] - self.vertices[0] + v = point - self.vertices[0] + u1 = np.dot(v, edge1) / np.dot(edge1, edge1) + u2 = np.dot(v, edge2) / np.dot(edge2, edge2) + return 0 <= u1 <= 1 and 0 <= u2 <= 1 + + def hit_by(self, ray) -> HitData | None: + intersection = self.plane.hit_by(ray) + if intersection is not None and self.is_point_in_face(intersection.hit_point): + return intersection + + def bounding_box(self): + my_min = np.minimum.reduce(self.vertices) + my_max = np.maximum.reduce(self.vertices) + return AABB3(my_min, my_max) + + up = np.array([-1.0, 1.0, 0.0]) up = up / np.linalg.norm(up) right = np.array([1.0, 1.0, 0.0]) diff --git a/pyraydeon/examples/py_rhombohedron.py b/pyraydeon/examples/py_rhombohedron.py new file mode 100644 index 0000000..c2e8d0f --- /dev/null +++ b/pyraydeon/examples/py_rhombohedron.py @@ -0,0 +1,155 @@ +"""Demonstrates custom objects with native collision geometry""" + +import numpy as np +import svg + +from pyraydeon import ( + Camera, + Geometry, + LineSegment3D, + Scene, + Quad, +) + + +class Rhombohedron(Geometry): + def __init__(self, origin, basis, dims): + basis = basis / np.linalg.norm(basis, axis=1, keepdims=True) + + self.origin = origin + self.basis = basis + self.dims = dims + + combinations = np.array(np.meshgrid([0, 1], [0, 1], [0, 1])).T.reshape(-1, 3) + scaled_combinations = combinations * dims + + # Transform the scaled combinations using the basis + transformed_vertices = np.dot(scaled_combinations, basis) + # Shift by the origin + self.vertices = transformed_vertices + origin + + # Make edges stand out slightly so as to not be intersected by their own faces + centroid = np.mean(self.vertices, axis=0) + vert_move_dirs = self.vertices - centroid + unit_move_dirs = vert_move_dirs / np.linalg.norm( + vert_move_dirs, axis=0, keepdims=True + ) + move_vectors = unit_move_dirs * 0.0015 + self.path_vertices = self.vertices + move_vectors + + self.faces = [ + [0, 1, 3, 2], # Bottom face + [4, 5, 7, 6], # Top face + [0, 1, 5, 4], # Front face + [2, 3, 7, 6], # Back face + [0, 2, 6, 4], # Left face + [1, 3, 7, 5], # Right face + ] + self.quads = self.compute_quads() + + def __repr__(self): + return f"Rhomboid(origin='{self.origin}', basis='{self.basis}', dims='{self.dims}')" + + def compute_quads(self): + quads = [] + for face in self.faces: + verts = self.vertices[face] + origin = verts[0] + basis = np.array( + [ + verts[1] - origin, + verts[3] - origin, + ] + ) + dims = np.linalg.norm(basis, axis=1) + + quads.append(Quad(origin, basis, dims)) + return quads + + def collision_geometry(self): + return [geom for quad in self.quads for geom in quad.collision_geometry()] + + def paths(self, cam): + edges = set( + [ + tuple(sorted((face[i], face[(i + 1) % len(face)]))) + for face in self.faces + for i in range(len(face)) + ] + ) + paths = [ + LineSegment3D(self.path_vertices[edge[0]], self.path_vertices[edge[1]]) + for edge in edges + ] + return paths + + +scene = Scene( + [ + Rhombohedron( + origin=np.array([0.0, 0.0, 0.0]), + basis=np.array( + [ + [0.9, 0.5, 0.0], + [-0.3, 1.0, 0.0], + [-0.5, 0.25, -0.7], + ] + ), + dims=np.array([1.0, 1.0, 1.0]), + ), + Rhombohedron( + origin=np.array([2.0, 0.0, -2.0]), + basis=np.array( + [ + [-0.9, 0.5, 0.0], + [0.3, 1.0, 0.5], + [1.5, 0.25, -0.7], + ] + ), + dims=np.array([1.0, 1.0, 1.0]), + ), + ] +) + +eye = np.array([0, 0.4, 5]) +focus = np.array([0, 0.4, 0]) +up = np.array([0, 1, 0]) + +fovy = 60.0 +width = 1024 +height = 1024 +znear = 0.1 +zfar = 20.0 + +cam = Camera.look_at(eye, focus, up).perspective(fovy, width, height, znear, zfar) + +paths = scene.render(cam) + +canvas = svg.SVG( + width="8in", + height="8in", + viewBox="0 0 1024 1024", +) +backing_rect = svg.Rect( + x=0, + y=0, + width="100%", + height="100%", + fill="white", +) +svg_lines = [ + svg.Line( + x1=f"{path.p1[0]}", + y1=f"{path.p1[1]}", + x2=f"{path.p2[0]}", + y2=f"{path.p2[1]}", + stroke_width="0.7mm", + stroke="black", + ) + for path in paths +] +line_group = svg.G(transform=f"translate(0, {height}) scale(1, -1)", elements=svg_lines) +canvas.elements = [backing_rect, line_group] + + +print(canvas) diff --git a/pyraydeon/examples/py_rhombohedron_expected.svg b/pyraydeon/examples/py_rhombohedron_expected.svg new file mode 100644 index 0000000..23143b2 --- /dev/null +++ b/pyraydeon/examples/py_rhombohedron_expected.svg @@ -0,0 +1 @@ + diff --git a/pyraydeon/src/linear.rs b/pyraydeon/src/linear.rs index b7ef730..d3ad9fc 100644 --- a/pyraydeon/src/linear.rs +++ b/pyraydeon/src/linear.rs @@ -1,9 +1,6 @@ -use numpy::{Ix1, PyArray, PyReadonlyArray1}; +use numpy::PyReadonlyArray1; use pyo3::exceptions::PyIndexError; use pyo3::prelude::*; -use raydeon::CollisionGeometry as _; - -use crate::ray::{HitData, Ray}; #[derive(Debug, Copy, Clone)] pub struct ArbitrarySpace; @@ -248,42 +245,8 @@ impl FloatIter { } } -pywrap!(AABB3, raydeon::AABB3); - -#[pymethods] -impl AABB3 { - #[new] - fn new(min: PyReadonlyArray1, max: PyReadonlyArray1) -> PyResult { - let min = Point3::try_from(min)?; - let max = Point3::try_from(max)?; - Ok(raydeon::AABB3::new(min.0, max.0).into()) - } - - #[getter] - fn min<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray> { - PyArray::from_slice_bound(py, &self.0.min.to_array()) - } - - #[getter] - fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray> { - PyArray::from_slice_bound(py, &self.0.max.to_array()) - } - - fn hit_by(&self, _py: Python, ray: Ray) -> Option { - raydeon::shapes::AxisAlignedCuboid::from(self.0.cast_unit()) - .hit_by(&ray.0) - .map(Into::into) - } - - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { - let class_name = slf.get_type().qualname()?; - Ok(format!("{}<{:?}>", class_name, slf.borrow().0)) - } -} - pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; - m.add_class::()?; Ok(()) } diff --git a/pyraydeon/src/ray.rs b/pyraydeon/src/ray.rs index a27c8e2..2a0a1b9 100644 --- a/pyraydeon/src/ray.rs +++ b/pyraydeon/src/ray.rs @@ -1,7 +1,8 @@ use numpy::{Ix1, PyArray, PyReadonlyArray1}; use pyo3::prelude::*; +use raydeon::CollisionGeometry as _; -use crate::linear::{Point3, Vec3}; +use crate::linear::{ArbitrarySpace, Point3, Vec3}; pywrap!(Ray, raydeon::Ray); @@ -56,8 +57,42 @@ impl HitData { } } +pywrap!(AABB3, raydeon::AABB3); + +#[pymethods] +impl AABB3 { + #[new] + fn new(min: PyReadonlyArray1, max: PyReadonlyArray1) -> PyResult { + let min = Point3::try_from(min)?; + let max = Point3::try_from(max)?; + Ok(raydeon::AABB3::new(min.0, max.0).into()) + } + + #[getter] + fn min<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray> { + PyArray::from_slice_bound(py, &self.0.min.to_array()) + } + + #[getter] + fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray> { + PyArray::from_slice_bound(py, &self.0.max.to_array()) + } + + fn hit_by(&self, _py: Python, ray: Ray) -> Option { + raydeon::shapes::AxisAlignedCuboid::from(self.0.cast_unit()) + .hit_by(&ray.0) + .map(Into::into) + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + Ok(format!("{}<{:?}>", class_name, slf.borrow().0)) + } +} + pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/pyraydeon/src/shapes/mod.rs b/pyraydeon/src/shapes/mod.rs index aa499e1..f0df791 100644 --- a/pyraydeon/src/shapes/mod.rs +++ b/pyraydeon/src/shapes/mod.rs @@ -1,4 +1,4 @@ -use primitive::Plane; +use primitive::{Plane, Quad}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyTuple}; use raydeon::WorldSpace; @@ -8,8 +8,7 @@ mod primitive; pub(crate) use primitive::{AxisAlignedCuboid, Tri}; -use crate::linear::AABB3; -use crate::ray::{HitData, Ray}; +use crate::ray::{HitData, Ray, AABB3}; use crate::scene::{Camera, LineSegment3D}; #[derive(Debug)] @@ -38,7 +37,7 @@ impl Geometry { pub(crate) fn geometry(&self, obj: PyObject) -> Arc> { match &self.geom { InnerGeometry::Native(ref geom) => Arc::clone(geom), - InnerGeometry::Py => Arc::new(PythonGeometry::new(obj)), + InnerGeometry::Py => Arc::new(PythonGeometry::new(obj, PythonGeometryKind::Draw)), } } } @@ -82,7 +81,7 @@ impl Geometry { fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; - Ok(format!("{}<{:?}>", class_name, slf.borrow().geom)) + Ok(format!("{}<{:#?}>", class_name, slf.borrow().geom)) } } @@ -141,25 +140,35 @@ impl CollisionGeometry { fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; - Ok(format!("{}<{:?}>", class_name, slf.borrow().geom)) + Ok(format!("{}<{:#?}>", class_name, slf.borrow().geom)) } } #[derive(Debug)] struct PythonGeometry { slf: PyObject, - aabb: Option, + kind: PythonGeometryKind, +} + +#[derive(Debug)] +enum PythonGeometryKind { + Draw, + Collision { aabb: Option }, } impl PythonGeometry { - fn new(slf: PyObject) -> Self { - Self { slf, aabb: None } + fn new(slf: PyObject, kind: PythonGeometryKind) -> Self { + Self { slf, kind } } fn as_collision_geometry(slf: PyObject) -> Self { - let mut ret = Self { slf, aabb: None }; + let mut ret = Self { + slf, + kind: PythonGeometryKind::Draw, + }; let aabb = raydeon::CollisionGeometry::bounding_box(&ret); - ret.aabb = aabb.map(|aabb| aabb.cast_unit().into()); + let aabb = aabb.map(|aabb| aabb.cast_unit().into()); + ret.kind = PythonGeometryKind::Collision { aabb }; ret } } @@ -211,7 +220,7 @@ impl raydeon::Shape for PythonGeometry { impl raydeon::CollisionGeometry for PythonGeometry { fn hit_by(&self, ray: &raydeon::Ray) -> Option { - if let Some(aabb) = self.aabb { + if let PythonGeometryKind::Collision { aabb: Some(aabb) } = self.kind { raydeon::shapes::AxisAlignedCuboid::from(aabb.0.cast_unit()).hit_by(ray)?; } Python::with_gil(|py| { @@ -243,6 +252,7 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; Ok(()) diff --git a/pyraydeon/src/shapes/primitive.rs b/pyraydeon/src/shapes/primitive.rs index 705fc92..ebc7629 100644 --- a/pyraydeon/src/shapes/primitive.rs +++ b/pyraydeon/src/shapes/primitive.rs @@ -1,5 +1,7 @@ use super::{CollisionGeometry, Geometry}; use crate::linear::{Point3, Vec3}; +use numpy::{PyArrayLike1, PyArrayLike2}; +use pyo3::exceptions::PyIndexError; use pyo3::prelude::*; use raydeon::WorldSpace; use std::sync::Arc; @@ -123,3 +125,72 @@ impl Plane { Ok((Self(shape), geom)) } } + +#[pyclass(frozen, extends=Geometry, subclass)] +pub(crate) struct Quad(pub(crate) Arc); + +impl ::std::ops::Deref for Quad { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for Quad { + fn from(value: Arc) -> Self { + Self(value) + } +} + +#[pymethods] +impl Quad { + #[new] + #[pyo3(signature = (origin, basis, dims, tag=0))] + fn new( + origin: &Bound<'_, PyAny>, + basis: PyArrayLike2<'_, f64>, + dims: PyArrayLike1<'_, f64>, + tag: usize, + ) -> PyResult<(Self, Geometry)> { + let origin: Point3 = origin.try_into()?; + let basis = basis + .as_array() + .as_slice() + .ok_or(PyIndexError::new_err("basis must be 2x3 array")) + .and_then(|slice| { + if slice.len() != 6 { + Err(PyIndexError::new_err("basis must be 2x3 array")) + } else { + Ok(slice) + } + }) + .map(|basis| { + [ + raydeon::Vec3::new(basis[0], basis[1], basis[2]), + raydeon::Vec3::new(basis[3], basis[4], basis[5]), + ] + })?; + let dims = dims + .as_array() + .as_slice() + .ok_or(PyIndexError::new_err("dims must be 1x2 array")) + .and_then(|slice| { + if slice.len() != 2 { + Err(PyIndexError::new_err("dims must be 1x2 array")) + } else { + Ok(slice) + } + }) + .map(|dims| [dims[0], dims[1]])?; + + let shape = Arc::new(raydeon::shapes::Quad::tagged( + origin.0.cast_unit(), + basis, + dims, + tag, + )); + let geom = Geometry::native(Arc::clone(&shape) as Arc>); + Ok((Self(shape), geom)) + } +} diff --git a/raydeon/src/shapes/aacuboid.rs b/raydeon/src/shapes/aacuboid.rs index 3212cd5..3605634 100644 --- a/raydeon/src/shapes/aacuboid.rs +++ b/raydeon/src/shapes/aacuboid.rs @@ -1,3 +1,4 @@ +//! Provides basic drawing and collision for axis-aligned cuboids. use std::sync::Arc; use collision::Continuous; diff --git a/raydeon/src/shapes/mod.rs b/raydeon/src/shapes/mod.rs index 461f9be..2e18284 100644 --- a/raydeon/src/shapes/mod.rs +++ b/raydeon/src/shapes/mod.rs @@ -1,9 +1,11 @@ pub use self::aacuboid::AxisAlignedCuboid; pub use self::plane::Plane; +pub use self::quad::Quad; pub use self::sphere::Sphere; pub use self::triangle::Triangle; pub mod aacuboid; pub mod plane; +pub mod quad; pub mod sphere; pub mod triangle; diff --git a/raydeon/src/shapes/plane.rs b/raydeon/src/shapes/plane.rs index 051ecf8..1f65b46 100644 --- a/raydeon/src/shapes/plane.rs +++ b/raydeon/src/shapes/plane.rs @@ -1,3 +1,4 @@ +//! Provides collision for 3D planes. use crate::{CollisionGeometry, HitData, Ray, WPoint3, WVec3, WorldSpace}; #[derive(Debug, Copy, Clone)] diff --git a/raydeon/src/shapes/quad.rs b/raydeon/src/shapes/quad.rs new file mode 100644 index 0000000..8b0bf70 --- /dev/null +++ b/raydeon/src/shapes/quad.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use super::Triangle; +use crate::path::LineSegment3D; +use crate::{Camera, CollisionGeometry, Shape, WPoint3, WVec3, WorldSpace}; + +#[derive(Debug, Copy, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub struct Quad { + pub origin: WPoint3, + pub basis: [WVec3; 2], + pub dims: [f64; 2], + pub verts: [WPoint3; 4], + tag: usize, +} + +impl Quad { + pub fn new(origin: WPoint3, basis: [WVec3; 2], dims: [f64; 2]) -> Quad { + Self::tagged(origin, basis, dims, 0) + } + + pub fn tagged(origin: WPoint3, mut basis: [WVec3; 2], dims: [f64; 2], tag: usize) -> Quad { + let verts = [ + origin, + origin + basis[0] * dims[0], + origin + basis[0] * dims[0] + basis[1] * dims[1], + origin + basis[1] * dims[1], + ]; + basis[0] = basis[0].normalize(); + basis[1] = basis[1].normalize(); + Quad { + origin, + basis, + dims, + verts, + tag, + } + } +} + +impl Shape for Quad { + fn collision_geometry(&self) -> Option>>> { + Some(vec![ + Arc::new(Triangle::new(self.verts[0], self.verts[1], self.verts[3])), + Arc::new(Triangle::new(self.verts[2], self.verts[3], self.verts[1])), + ]) + } + + fn paths(&self, _cam: &Camera) -> Vec> { + let centroid = self + .verts + .into_iter() + .map(WPoint3::to_vector) + .sum::() + / 4.0; + + let v = self + .verts + .into_iter() + .map(|v| v + (v.to_vector() - centroid).normalize() * 0.0015) + .collect::>(); + + vec![ + LineSegment3D::tagged(v[0], v[1], self.tag), + LineSegment3D::tagged(v[1], v[2], self.tag), + LineSegment3D::tagged(v[2], v[3], self.tag), + LineSegment3D::tagged(v[3], v[0], self.tag), + ] + } +} diff --git a/raydeon/src/shapes/sphere.rs b/raydeon/src/shapes/sphere.rs index 785eaad..9ac1434 100644 --- a/raydeon/src/shapes/sphere.rs +++ b/raydeon/src/shapes/sphere.rs @@ -1,3 +1,4 @@ +//! Provides collision for spheres. use crate::{CollisionGeometry, HitData, Ray, WPoint3, WVec3, WorldSpace}; #[derive(Debug, Copy, Clone)] diff --git a/raydeon/src/shapes/triangle.rs b/raydeon/src/shapes/triangle.rs index 130a4c4..dc7d6f0 100644 --- a/raydeon/src/shapes/triangle.rs +++ b/raydeon/src/shapes/triangle.rs @@ -1,3 +1,4 @@ +//! Provides basic drawing and collision for triangles. use std::sync::Arc; use super::plane::Plane;