Skip to content

Commit

Permalink
feat: allow shapes to provide a set of collision geometry
Browse files Browse the repository at this point in the history
This allows composite geometry to benefit from the bounding volume
hierarchy optimizations.
  • Loading branch information
cbgbt committed Nov 20, 2024
1 parent 4253815 commit 8b20063
Show file tree
Hide file tree
Showing 18 changed files with 339 additions and 174 deletions.
3 changes: 2 additions & 1 deletion pyraydeon/check-examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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
Expand All @@ -30,7 +31,7 @@ for example in ${SCRIPT_DIR}/examples/*.py; do
echo "Running example: $example_name"
outpath=$(mktemp)

uv --project ${SCRIPT_DIR} run ${example} | resvg --resources-dir . - ${outpath}
time uv --project ${SCRIPT_DIR} run ${example} | resvg --resources-dir . - ${outpath}

outpath_expected=$(mktemp)
resvg ${SCRIPT_DIR}/examples/${example_name}_expected.svg ${outpath_expected}
Expand Down
85 changes: 44 additions & 41 deletions pyraydeon/examples/py_cubes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,48 @@
import numpy as np
import svg

from pyraydeon import AABB3, Camera, Geometry, HitData, LineSegment3D, Scene
from pyraydeon import (
AABB3,
Camera,
Geometry,
HitData,
LineSegment3D,
Scene,
CollisionGeometry,
Plane,
)


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:
if not self.bounding_box().hit_by(ray):
return 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):
Expand Down Expand Up @@ -73,50 +114,12 @@ def __init__(
[2, 3, 7, 6],
[3, 0, 4, 7],
]
self.planes = [self.compute_plane(self.vertices[face]) for face in self.faces]

def __repr__(self):
return f"RectPrism(basis='[{self.right}, {self.up}, {self.fwd}]', dims='[{self.width}, {self.height}, {self.depth}]')"

def compute_plane(self, points):
p1, p2, p3 = points[:3]
normal = np.cross(p2 - p1, p3 - p1)
normal /= np.linalg.norm(normal)
d = -np.dot(normal, p1)
return normal, d

def ray_intersects_plane(self, ray, plane) -> HitData | None:
normal, d = plane

denom = np.dot(normal, ray.dir)
if abs(denom) < 1e-6:
return None
t = -(np.dot(normal, ray.point) + d) / denom
return HitData(ray.point + t * ray.dir, t) if t >= 0 else None

def is_point_in_face(self, point, face):
face_vertices = self.vertices[face]
edge1 = face_vertices[1] - face_vertices[0]
edge2 = face_vertices[3] - face_vertices[0]
v = point - face_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:
if not self.bounding_box().hit_by(ray):
return None
for face, plane in zip(self.faces, self.planes):
intersection = self.ray_intersects_plane(ray, plane)
if intersection is not None and self.is_point_in_face(
intersection.hit_point, face
):
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)
def collision_geometry(self):
return [Quad(self.vertices[face]) for face in self.faces]

def paths(self, cam):
edges = set(
Expand Down
3 changes: 3 additions & 0 deletions pyraydeon/examples/triangles.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ class CustomTriangle(Geometry):
def __init__(self, p1, p2, p3):
self.tri = Tri(p1, p2, p3)

def collision_geomery(self):
return [self]

def hit_by(self, ray):
return self.tri.hit_by(ray)

Expand Down
2 changes: 1 addition & 1 deletion pyraydeon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use pyo3::prelude::*;

macro_rules! pywrap {
($name:ident, $wraps:ty) => {
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Copy)]
#[pyclass(frozen)]
pub(crate) struct $name(pub(crate) $wraps);

Expand Down
51 changes: 36 additions & 15 deletions pyraydeon/src/linear.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use numpy::PyArray1;
use numpy::{Ix1, PyArray, PyReadonlyArray1};
use pyo3::exceptions::PyIndexError;
use pyo3::prelude::*;
use raydeon::Shape;
use raydeon::CollisionGeometry as _;

use crate::ray::{HitData, Ray};

Expand Down Expand Up @@ -90,6 +90,17 @@ impl TryFrom<&Bound<'_, PyAny>> for Vec3 {
}
}

impl TryFrom<PyReadonlyArray1<'_, f64>> for Vec3 {
type Error = PyErr;

fn try_from(value: PyReadonlyArray1<f64>) -> Result<Self, Self::Error> {
let value = value
.as_slice()
.map_err(|_| pyo3::exceptions::PyValueError::new_err("hit_point must be a 1D array"))?;
Ok(Self::new(value[0], value[1], value[2]))
}
}

pywrap!(Point3, raydeon::Point3<ArbitrarySpace>);

#[pymethods]
Expand Down Expand Up @@ -155,6 +166,17 @@ impl TryFrom<&Bound<'_, PyAny>> for Point3 {
}
}

impl TryFrom<PyReadonlyArray1<'_, f64>> for Point3 {
type Error = PyErr;

fn try_from(value: PyReadonlyArray1<f64>) -> Result<Self, Self::Error> {
let value = value
.as_slice()
.map_err(|_| pyo3::exceptions::PyValueError::new_err("hit_point must be a 1D array"))?;
Ok(Self::new(value[0], value[1], value[2]))
}
}

pywrap!(Point2, raydeon::Point2<ArbitrarySpace>);

#[pymethods]
Expand Down Expand Up @@ -199,13 +221,14 @@ impl Point2 {
}
}

impl TryFrom<&Bound<'_, PyAny>> for Point2 {
impl TryFrom<PyReadonlyArray1<'_, f64>> for Point2 {
type Error = PyErr;

fn try_from(value: &Bound<'_, PyAny>) -> Result<Self, Self::Error> {
let x = value.get_item(0)?.extract()?;
let y = value.get_item(1)?.extract()?;
Ok(Self::new(x, y))
fn try_from(value: PyReadonlyArray1<f64>) -> Result<Self, Self::Error> {
let value = value
.as_slice()
.map_err(|_| pyo3::exceptions::PyValueError::new_err("hit_point must be a 1D array"))?;
Ok(Self::new(value[0], value[1]))
}
}

Expand All @@ -230,25 +253,23 @@ pywrap!(AABB3, raydeon::AABB3<ArbitrarySpace>);
#[pymethods]
impl AABB3 {
#[new]
fn new(min: &Bound<'_, PyAny>, max: &Bound<'_, PyAny>) -> PyResult<Self> {
fn new(min: PyReadonlyArray1<f64>, max: PyReadonlyArray1<f64>) -> PyResult<Self> {
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, PyArray1<f64>> {
let min = [self.0.min.x, self.0.min.y, self.0.min.z];
PyArray1::from_slice_bound(py, &min)
fn min<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.min.to_array())
}

#[getter]
fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let max = [self.0.max.x, self.0.max.y, self.0.max.z];
PyArray1::from_slice_bound(py, &max)
fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.max.to_array())
}

fn hit_by(&self, ray: Ray) -> Option<HitData> {
fn hit_by(&self, _py: Python, ray: Ray) -> Option<HitData> {
raydeon::shapes::AxisAlignedCuboid::from(self.0.cast_unit())
.hit_by(&ray.0)
.map(Into::into)
Expand Down
21 changes: 9 additions & 12 deletions pyraydeon/src/ray.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use numpy::PyArray1;
use numpy::{Ix1, PyArray, PyReadonlyArray1};
use pyo3::prelude::*;

use crate::linear::{Point3, Vec3};
Expand All @@ -8,22 +8,20 @@ pywrap!(Ray, raydeon::Ray);
#[pymethods]
impl Ray {
#[new]
fn new(point: &Bound<'_, PyAny>, dir: &Bound<'_, PyAny>) -> PyResult<Self> {
fn new(point: PyReadonlyArray1<f64>, dir: PyReadonlyArray1<f64>) -> PyResult<Self> {
let point = Point3::try_from(point)?;
let dir = Vec3::try_from(dir)?;
Ok(raydeon::Ray::new(point.0.cast_unit(), dir.0.cast_unit()).into())
}

#[getter]
fn point<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let point = [self.0.point.x, self.0.point.y, self.0.point.z];
PyArray1::from_slice_bound(py, &point)
fn point<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.point.to_array())
}

#[getter]
fn dir<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let dir = [self.0.dir.x, self.0.dir.y, self.0.dir.z];
PyArray1::from_slice_bound(py, &dir)
fn dir<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.dir.to_array())
}

fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> {
Expand All @@ -37,15 +35,14 @@ pywrap!(HitData, raydeon::HitData);
#[pymethods]
impl HitData {
#[new]
fn new(hit_point: &Bound<'_, PyAny>, dist_to: f64) -> PyResult<Self> {
fn new(hit_point: PyReadonlyArray1<f64>, dist_to: f64) -> PyResult<Self> {
let hit_point = Point3::try_from(hit_point)?;
Ok(raydeon::HitData::new(hit_point.0.cast_unit(), dist_to).into())
}

#[getter]
fn hit_point<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let hp = [self.0.hit_point.x, self.0.hit_point.y, self.0.hit_point.z];
PyArray1::from_slice_bound(py, &hp)
fn hit_point<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.hit_point.to_array())
}

#[getter]
Expand Down
24 changes: 10 additions & 14 deletions pyraydeon/src/scene.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::sync::Arc;

use numpy::PyArray1;
use numpy::{Ix1, PyArray, PyReadonlyArray1};
use pyo3::prelude::*;
use raydeon::WorldSpace;

Expand Down Expand Up @@ -86,22 +86,20 @@ pywrap!(LineSegment2D, raydeon::path::LineSegment2D<ArbitrarySpace>);
#[pymethods]
impl LineSegment2D {
#[new]
fn new(p1: &Bound<'_, PyAny>, p2: &Bound<'_, PyAny>) -> PyResult<Self> {
fn new(p1: PyReadonlyArray1<f64>, p2: PyReadonlyArray1<f64>) -> PyResult<Self> {
let p1 = Point2::try_from(p1)?;
let p2 = Point2::try_from(p2)?;
Ok(raydeon::path::LineSegment2D::new(p1.cast_unit(), p2.cast_unit()).into())
}

#[getter]
fn p1<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let p1 = [self.0.p1.x, self.0.p1.y];
PyArray1::from_slice_bound(py, &p1)
fn p1<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.p1.to_array())
}

#[getter]
fn p2<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let p2 = [self.0.p2.x, self.0.p2.y];
PyArray1::from_slice_bound(py, &p2)
fn p2<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.p2.to_array())
}

fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> {
Expand All @@ -122,15 +120,13 @@ impl LineSegment3D {
}

#[getter]
fn p1<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let p1 = [self.0.p1.x, self.0.p1.y, self.0.p1.z];
PyArray1::from_slice_bound(py, &p1)
fn p1<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.p1.to_array())
}

#[getter]
fn p2<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1<f64>> {
let p2 = [self.0.p2.x, self.0.p2.y, self.0.p2.z];
PyArray1::from_slice_bound(py, &p2)
fn p2<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
PyArray::from_slice_bound(py, &self.0.p2.to_array())
}

fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> {
Expand Down
Loading

0 comments on commit 8b20063

Please sign in to comment.