Planes, rays and a local picking query¶
Intermediate. Prerequisites: dot products and, for screen picking, camera unprojection. Derive ray-plane intersection and interpret hits without treating Ray's historical state as a collision API.
Solve the plane equation along a ray¶
Plane coefficients satisfy n·p+d=0, where n=[a,b,c]. A ray's mathematical half-line is p(t)=o+t*u, t≥0. Substitution gives t=-(n·o+d)/(n·u) when denominator is nonzero. For unit u, t measures geometric distance. Multiplying all plane coefficients by the same nonzero scalar leaves t unchanged. Only a unit plane normal makes its position evaluation a signed distance; normalization must scale d as well.
Ray construction retains start/direction Vector references and normalizes the
caller direction in place. Its .distance stores the original direction length;
it is not automatically a hit distance or an interval bound. .end is an
intersection placeholder/state with no validity flag. The local query below
returns a separate status, parameter and hit point and does not mutate that state.
An exactly zero denominator means parallel; a zero numerator too means the whole line lies in the plane, not a unique hit. t<0 lies behind the ray origin. This example uses exact-zero comparisons and an unbounded forward half-line. Choosing a near-parallel tolerance or finite range is an application-specific numerical policy, not an implicit rule added to gem.
Local ray-plane calculation¶
# Use the plane equation n dot p + d = 0 for the independent query.
from gem.plane import Plane
from gem.ray import Ray
from gem.vector import Vector
# Tutorial-local calculation, not a gem intersection method.
def query_plane(ray, plane):
value = plane.dot(Vector(4, ray.start.vector + [1.0]))
denominator = plane.normal.dot(ray.dir)
if denominator == 0.0:
return ('coplanar' if value == 0.0 else 'parallel'), None, None
t = -value / denominator
if t < 0.0:
return 'behind', t, None
return 'hit', t, ray.start + ray.dir * t
plane = Plane()
plane.fromCoeffs(0, 0, 2, -2) # z=1, deliberately nonunit normal
start = Vector(3, [0, 0, 3])
direction = Vector(3, [0, 0, -2])
ray = Ray(start, direction)
status, t, hit = query_plane(ray, plane)
assert status == 'hit' and t == 2.0 and hit.vector == [0.0, 0.0, 1.0]
assert plane.dot(Vector(4, hit.vector + [1])) == 0
assert ray.distance == 2 and ray.end.vector == [0.0, 0.0, 0.0]
assert direction.vector == [0.0, 0.0, -1.0] and ray.start is start
assert query_plane(ray, plane.normalize())[1] == t
parallel = Ray(Vector(3, [0, 0, 3]), Vector(3, [1, 0, 0]))
coplanar = Ray(Vector(3, [0, 0, 1]), Vector(3, [1, 0, 0]))
behind = Ray(Vector(3, [0, 0, 0]), Vector(3, [0, 0, -1]))
assert query_plane(parallel, plane)[0] == 'parallel'
assert query_plane(coplanar, plane)[0] == 'coplanar'
assert query_plane(behind, plane)[0] == 'behind'
copy = ray.duplicate()
assert copy.start.vector is not ray.start.vector and copy.end is not ray.end
print(status, t, hit.vector)
Output: hit 2.0 [0.0, 0.0, 1.0]. Plane evaluation starts at 4 and decreases by
2 per unit ray distance, giving t=2. The constructor changed the supplied
Vector direction, while duplication copies every Vector and its storage without
rerunning normalization. Coefficients/normal must remain synchronized: arbitrary
field edits can make this local query inconsistent.
Build a picking ray¶
Unproject the same window XY at depth 0 and 1 to obtain near/far positions. Their difference gives a direction through that screen location; this example chooses the near-plane position as ray origin. It is a local construction, not an exposed picking helper or general scene intersection system.
from gem.matrix import lookAt, perspective, unproject
from gem.ray import Ray
from gem.vector import Vector
view = lookAt(Vector(3, [0, 0, 5]), Vector(3, [0, 0, 0]), Vector(3, [0, 1, 0]))
projection = perspective(90, 2, 1, 10)
viewport = [10, 20, 200, 100]
near = unproject(110, 70, 0, view, projection, viewport)
far = unproject(110, 70, 1, view, projection, viewport)
assert all(abs(x - e) < 1e-13 for x, e in zip(near.vector, [0, 0, 4]))
assert all(abs(x - e) < 1e-13 for x, e in zip(far.vector, [0, 0, -5]))
picking = Ray(near, far - near)
assert all(abs(x - e) < 1e-14 for x, e in zip(picking.dir.vector, [0, 0, -1]))
assert abs(picking.distance - 9) < 1e-13
point = picking.start + picking.dir * 4.0
assert all(abs(x) < 1e-13 for x in point.vector)
assert picking.end.vector == [0.0, 0.0, 0.0]
The center pixel line travels along -Z and reaches the world origin four units after the near plane. Its stored distance happens to be the near/far separation; an application must explicitly enforce any finite query interval. Unprojection's zero-W sentinel cannot itself identify a valid picking ray; degenerate direction construction rejects zero with ZeroDivisionError.
Limits and uses¶
Ray/Plane support placement, construction and rigid transforms, not AABB/triangle intersection, collision acceleration or scene visibility. Ray transforms rotate about the coordinate origin, preserve distance and leave end untouched. Do not set end automatically just because the query returned a point; choose application hit ownership/validity explicitly. Near-parallel division, coefficient scale and ill-conditioned projection inverses can amplify errors; no broad epsilon policy is established. Normalizing a zero-normal plane or constructing a plane from collinear points raises ZeroDivisionError; fromCoeffs itself does not enforce a nonzero normal. The local query assumes a valid plane with synchronized fields. Nonfinite and malformed inputs are outside this example's domain.
See Plane API, Ray API, hit-state decisions, camera, numerical accuracy and tutorial index.