Skip to content

Commit 6b8950a

Browse files
committed
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.
1 parent 19d90b5 commit 6b8950a

File tree

14 files changed

+406
-87
lines changed

14 files changed

+406
-87
lines changed

Makefile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ check-fmt:
1010
cargo fmt --check
1111
uv --project pyraydeon run ruff format --check pyraydeon
1212

13-
.PHONY: render-test
14-
render-test:
13+
.PHONY: rust-render-test
14+
rust-render-test:
1515
./raydeon/check-examples.sh
16+
17+
.PHONY: reinstall-py-venv
18+
reinstall-py-venv:
19+
echo "Reinstalling native dependencies in virtualenv..."
20+
uv --project pyraydeon run --reinstall python -c 'print("Reinstalled dependencies")'
21+
22+
23+
.PHONY: py-render-test
24+
py-render-test: reinstall-py-venv
1625
./pyraydeon/check-examples.sh
1726

27+
.PHONY: render-test
28+
render-test: rust-render-test py-render-test
29+
1830
.PHONY: unit-test
1931
unit-test:
2032
cargo test --locked

pyraydeon/check-examples.sh

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@ if [ "$ALL_TOOLS_FOUND" = false ]; then
2222
exit 1
2323
fi
2424

25-
echo "Reinstalling native dependencies in virtualenv..."
26-
uv --project ${SCRIPT_DIR} run --reinstall python -c 'print("Reinstalled dependencies")'
27-
2825
for example in ${SCRIPT_DIR}/examples/*.py; do
2926
example_name=$(basename "$example" .py)
3027

pyraydeon/examples/py_cubes.py

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,36 +19,6 @@
1919
)
2020

2121

22-
class Quad(CollisionGeometry):
23-
def __init__(self, vertices):
24-
self.vertices = vertices
25-
self.plane = self.compute_plane(vertices)
26-
27-
def compute_plane(self, points):
28-
p1, p2, p3 = points[:3]
29-
normal = np.cross(p2 - p1, p3 - p1)
30-
normal /= np.linalg.norm(normal)
31-
return Plane(p1, normal)
32-
33-
def is_point_in_face(self, point):
34-
edge1 = self.vertices[1] - self.vertices[0]
35-
edge2 = self.vertices[3] - self.vertices[0]
36-
v = point - self.vertices[0]
37-
u1 = np.dot(v, edge1) / np.dot(edge1, edge1)
38-
u2 = np.dot(v, edge2) / np.dot(edge2, edge2)
39-
return 0 <= u1 <= 1 and 0 <= u2 <= 1
40-
41-
def hit_by(self, ray) -> HitData | None:
42-
intersection = self.plane.hit_by(ray)
43-
if intersection is not None and self.is_point_in_face(intersection.hit_point):
44-
return intersection
45-
46-
def bounding_box(self):
47-
my_min = np.minimum.reduce(self.vertices)
48-
my_max = np.maximum.reduce(self.vertices)
49-
return AABB3(my_min, my_max)
50-
51-
5222
class RectPrism(Geometry):
5323
def __init__(
5424
self,
@@ -117,7 +87,7 @@ def __repr__(self):
11787
return f"RectPrism(basis='[{self.right}, {self.up}, {self.fwd}]', dims='[{self.width}, {self.height}, {self.depth}]')"
11888

11989
def collision_geometry(self):
120-
return [Quad(self.vertices[face]) for face in self.faces]
90+
return [PyQuad(self.vertices[face]) for face in self.faces]
12191

12292
def paths(self, cam):
12393
edges = set(
@@ -134,6 +104,36 @@ def paths(self, cam):
134104
return paths
135105

136106

107+
class PyQuad(CollisionGeometry):
108+
def __init__(self, vertices):
109+
self.vertices = vertices
110+
self.plane = self.compute_plane(vertices)
111+
112+
def compute_plane(self, points):
113+
p1, p2, p3 = points[:3]
114+
normal = np.cross(p2 - p1, p3 - p1)
115+
normal /= np.linalg.norm(normal)
116+
return Plane(p1, normal)
117+
118+
def is_point_in_face(self, point):
119+
edge1 = self.vertices[1] - self.vertices[0]
120+
edge2 = self.vertices[3] - self.vertices[0]
121+
v = point - self.vertices[0]
122+
u1 = np.dot(v, edge1) / np.dot(edge1, edge1)
123+
u2 = np.dot(v, edge2) / np.dot(edge2, edge2)
124+
return 0 <= u1 <= 1 and 0 <= u2 <= 1
125+
126+
def hit_by(self, ray) -> HitData | None:
127+
intersection = self.plane.hit_by(ray)
128+
if intersection is not None and self.is_point_in_face(intersection.hit_point):
129+
return intersection
130+
131+
def bounding_box(self):
132+
my_min = np.minimum.reduce(self.vertices)
133+
my_max = np.maximum.reduce(self.vertices)
134+
return AABB3(my_min, my_max)
135+
136+
137137
up = np.array([-1.0, 1.0, 0.0])
138138
up = up / np.linalg.norm(up)
139139
right = np.array([1.0, 1.0, 0.0])
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Demonstrates custom objects with native collision geometry"""
2+
3+
import numpy as np
4+
import svg
5+
6+
from pyraydeon import (
7+
Camera,
8+
Geometry,
9+
LineSegment3D,
10+
Scene,
11+
Quad,
12+
)
13+
14+
15+
class Rhombohedron(Geometry):
16+
def __init__(self, origin, basis, dims):
17+
basis = basis / np.linalg.norm(basis, axis=1, keepdims=True)
18+
19+
self.origin = origin
20+
self.basis = basis
21+
self.dims = dims
22+
23+
combinations = np.array(np.meshgrid([0, 1], [0, 1], [0, 1])).T.reshape(-1, 3)
24+
scaled_combinations = combinations * dims
25+
26+
# Transform the scaled combinations using the basis
27+
transformed_vertices = np.dot(scaled_combinations, basis)
28+
# Shift by the origin
29+
self.vertices = transformed_vertices + origin
30+
31+
# Make edges stand out slightly so as to not be intersected by their own faces
32+
centroid = np.mean(self.vertices, axis=0)
33+
vert_move_dirs = self.vertices - centroid
34+
unit_move_dirs = vert_move_dirs / np.linalg.norm(
35+
vert_move_dirs, axis=0, keepdims=True
36+
)
37+
move_vectors = unit_move_dirs * 0.0015
38+
self.path_vertices = self.vertices + move_vectors
39+
40+
self.faces = [
41+
[0, 1, 3, 2], # Bottom face
42+
[4, 5, 7, 6], # Top face
43+
[0, 1, 5, 4], # Front face
44+
[2, 3, 7, 6], # Back face
45+
[0, 2, 6, 4], # Left face
46+
[1, 3, 7, 5], # Right face
47+
]
48+
self.quads = self.compute_quads()
49+
50+
def __repr__(self):
51+
return f"Rhomboid(origin='{self.origin}', basis='{self.basis}', dims='{self.dims}')"
52+
53+
def compute_quads(self):
54+
quads = []
55+
for face in self.faces:
56+
verts = self.vertices[face]
57+
origin = verts[0]
58+
basis = np.array(
59+
[
60+
verts[1] - origin,
61+
verts[3] - origin,
62+
]
63+
)
64+
dims = np.linalg.norm(basis, axis=1)
65+
66+
quads.append(Quad(origin, basis, dims))
67+
return quads
68+
69+
def collision_geometry(self):
70+
return [geom for quad in self.quads for geom in quad.collision_geometry()]
71+
72+
def paths(self, cam):
73+
edges = set(
74+
[
75+
tuple(sorted((face[i], face[(i + 1) % len(face)])))
76+
for face in self.faces
77+
for i in range(len(face))
78+
]
79+
)
80+
paths = [
81+
LineSegment3D(self.path_vertices[edge[0]], self.path_vertices[edge[1]])
82+
for edge in edges
83+
]
84+
return paths
85+
86+
87+
scene = Scene(
88+
[
89+
Rhombohedron(
90+
origin=np.array([0.0, 0.0, 0.0]),
91+
basis=np.array(
92+
[
93+
[0.9, 0.5, 0.0],
94+
[-0.3, 1.0, 0.0],
95+
[-0.5, 0.25, -0.7],
96+
]
97+
),
98+
dims=np.array([1.0, 1.0, 1.0]),
99+
),
100+
Rhombohedron(
101+
origin=np.array([2.0, 0.0, -2.0]),
102+
basis=np.array(
103+
[
104+
[-0.9, 0.5, 0.0],
105+
[0.3, 1.0, 0.5],
106+
[1.5, 0.25, -0.7],
107+
]
108+
),
109+
dims=np.array([1.0, 1.0, 1.0]),
110+
),
111+
]
112+
)
113+
114+
eye = np.array([0, 0.4, 5])
115+
focus = np.array([0, 0.4, 0])
116+
up = np.array([0, 1, 0])
117+
118+
fovy = 60.0
119+
width = 1024
120+
height = 1024
121+
znear = 0.1
122+
zfar = 20.0
123+
124+
cam = Camera.look_at(eye, focus, up).perspective(fovy, width, height, znear, zfar)
125+
126+
paths = scene.render(cam)
127+
128+
canvas = svg.SVG(
129+
width="8in",
130+
height="8in",
131+
viewBox="0 0 1024 1024",
132+
)
133+
backing_rect = svg.Rect(
134+
x=0,
135+
y=0,
136+
width="100%",
137+
height="100%",
138+
fill="white",
139+
)
140+
svg_lines = [
141+
svg.Line(
142+
x1=f"{path.p1[0]}",
143+
y1=f"{path.p1[1]}",
144+
x2=f"{path.p2[0]}",
145+
y2=f"{path.p2[1]}",
146+
stroke_width="0.7mm",
147+
stroke="black",
148+
)
149+
for path in paths
150+
]
151+
line_group = svg.G(transform=f"translate(0, {height}) scale(1, -1)", elements=svg_lines)
152+
canvas.elements = [backing_rect, line_group]
153+
154+
155+
print(canvas)

pyraydeon/src/linear.rs

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
use numpy::{Ix1, PyArray, PyReadonlyArray1};
1+
use numpy::PyReadonlyArray1;
22
use pyo3::exceptions::PyIndexError;
33
use pyo3::prelude::*;
4-
use raydeon::CollisionGeometry as _;
5-
6-
use crate::ray::{HitData, Ray};
74

85
#[derive(Debug, Copy, Clone)]
96
pub struct ArbitrarySpace;
@@ -248,42 +245,8 @@ impl FloatIter {
248245
}
249246
}
250247

251-
pywrap!(AABB3, raydeon::AABB3<ArbitrarySpace>);
252-
253-
#[pymethods]
254-
impl AABB3 {
255-
#[new]
256-
fn new(min: PyReadonlyArray1<f64>, max: PyReadonlyArray1<f64>) -> PyResult<Self> {
257-
let min = Point3::try_from(min)?;
258-
let max = Point3::try_from(max)?;
259-
Ok(raydeon::AABB3::new(min.0, max.0).into())
260-
}
261-
262-
#[getter]
263-
fn min<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
264-
PyArray::from_slice_bound(py, &self.0.min.to_array())
265-
}
266-
267-
#[getter]
268-
fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
269-
PyArray::from_slice_bound(py, &self.0.max.to_array())
270-
}
271-
272-
fn hit_by(&self, _py: Python, ray: Ray) -> Option<HitData> {
273-
raydeon::shapes::AxisAlignedCuboid::from(self.0.cast_unit())
274-
.hit_by(&ray.0)
275-
.map(Into::into)
276-
}
277-
278-
fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> {
279-
let class_name = slf.get_type().qualname()?;
280-
Ok(format!("{}<{:?}>", class_name, slf.borrow().0))
281-
}
282-
}
283-
284248
pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
285249
m.add_class::<Vec3>()?;
286250
m.add_class::<Point3>()?;
287-
m.add_class::<AABB3>()?;
288251
Ok(())
289252
}

pyraydeon/src/ray.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use numpy::{Ix1, PyArray, PyReadonlyArray1};
22
use pyo3::prelude::*;
3+
use raydeon::CollisionGeometry as _;
34

4-
use crate::linear::{Point3, Vec3};
5+
use crate::linear::{ArbitrarySpace, Point3, Vec3};
56

67
pywrap!(Ray, raydeon::Ray);
78

@@ -56,8 +57,42 @@ impl HitData {
5657
}
5758
}
5859

60+
pywrap!(AABB3, raydeon::AABB3<ArbitrarySpace>);
61+
62+
#[pymethods]
63+
impl AABB3 {
64+
#[new]
65+
fn new(min: PyReadonlyArray1<f64>, max: PyReadonlyArray1<f64>) -> PyResult<Self> {
66+
let min = Point3::try_from(min)?;
67+
let max = Point3::try_from(max)?;
68+
Ok(raydeon::AABB3::new(min.0, max.0).into())
69+
}
70+
71+
#[getter]
72+
fn min<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
73+
PyArray::from_slice_bound(py, &self.0.min.to_array())
74+
}
75+
76+
#[getter]
77+
fn max<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray<f64, Ix1>> {
78+
PyArray::from_slice_bound(py, &self.0.max.to_array())
79+
}
80+
81+
fn hit_by(&self, _py: Python, ray: Ray) -> Option<HitData> {
82+
raydeon::shapes::AxisAlignedCuboid::from(self.0.cast_unit())
83+
.hit_by(&ray.0)
84+
.map(Into::into)
85+
}
86+
87+
fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> {
88+
let class_name = slf.get_type().qualname()?;
89+
Ok(format!("{}<{:?}>", class_name, slf.borrow().0))
90+
}
91+
}
92+
5993
pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
6094
m.add_class::<Ray>()?;
6195
m.add_class::<HitData>()?;
96+
m.add_class::<AABB3>()?;
6297
Ok(())
6398
}

0 commit comments

Comments
 (0)