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;