Blender ray casting: a hit is not an inside test

If a ray starts inside a closed box, it can hit a wall on the way out. If it starts outside the same box, it can hit a wall on the way in. A successful surface hit therefore does not, by itself, tell you whether the ray’s origin was inside.

A miss is equally ambiguous. Limit an interior ray to less than the distance to the wall and it misses, despite starting inside. Point an exterior ray away from the box and it also misses. Before using a ray result as a condition in a Blender script, decide whether you need a first surface intersection or a classification of the origin.

Four casts, two different questions

Use a synthetic box centered at the origin with half extents of 0.4 m in X, 0.3 m in Y and 0.2 m in Z.

From the center, a +X cast with a 1 m limit reaches the far face after 0.4 m. From X=−0.8 m, another +X cast reaches the near face after the same 0.4 m. The first origin is inside and the second outside; both mathematical casts have a hit at the same travel distance.

Now shorten the interior cast to 0.2 m: it stops before reaching a surface. An exterior cast aimed in −X also finds no forward intersection. Neither miss describes the volume around its origin.

Four actual Blender ray-query views of a closed-box edge outline: inside and outside origins each give a hit and a miss under different finite directions or limits
Actual Blender casts produce a hit from both an inside and an outside origin, and a miss from each. The wire outline represents a closed box. Drawing aids and camera framing are not length measurements.

The code below reproduces those four cases with a slab intersection calculation. Its origin classification is a separate coordinate check for this particular box. It calls no Blender API:

import math

half = (0.4, 0.3, 0.2)  # metres; this specific closed box only

def origin_class(p):
    if any(abs(x) > h for x, h in zip(p, half)):
        return 'outside'
    return 'boundary' if any(abs(x) == h for x, h in zip(p, half)) else 'inside'

def first_surface(p, direction, limit):
    norm = math.hypot(*direction)
    if norm == 0 or limit < 0:
        raise ValueError('A ray needs a direction and a nonnegative range')
    enter, leave = -math.inf, math.inf
    for x, raw, h in zip(p, direction, half):
        d = raw / norm
        if d == 0:
            if abs(x) > h:
                return None
            continue
        a, b = (-h - x) / d, (h - x) / d
        enter, leave = max(enter, min(a, b)), min(leave, max(a, b))
    if enter > leave or leave < 0:
        return None
    t = enter if enter >= 0 else leave
    return t if t <= limit else None

for p, direction, limit in [
    ((0, 0, 0), (1, 0, 0), 1.0),
    ((-0.8, 0, 0), (1, 0, 0), 1.0),
    ((0, 0, 0), (1, 0, 0), 0.2),
    ((-0.8, 0, 0), (-1, 0, 0), 1.0),
]:
    t = first_surface(p, direction, limit)
    distance = 'None' if t is None else f'{t:.3f} m'
    print(f'{origin_class(p):7s}  hit={str(t is not None):5s}  first={distance}')

assert origin_class((0.4, 0, 0)) == 'boundary'
print('Own box mathematics; no Blender API call was made.')

The first two lines print hit=True and first=0.400 m, once for an inside origin and once for an outside origin. The last two print hit=False and first=None, again with different origin classes. This is a counterexample to interpreting the hit Boolean as containment.

Actual Scene and Object API check

An AI-operated Blender 5.2.2 LTS experiment ran the eight valid queries with both APIs: 16 actual calls. The measured mesh had eight vertices, twelve edges and six outward faces, with no modifiers or instances. The separate query-wire object had no faces, so it could not add another hit surface. The box was translated to (1.2, −0.5, 0.8) m in world coordinates; the local cases above describe the same shape.

Local origin Direction / limit Independent origin class Actual hit, both APIs First travel
(0, 0, 0) m +X / 1.0 m Inside Yes 0.400 m
(−0.8, 0, 0) m +X / 1.0 m Outside Yes 0.400 m
(0, 0, 0) m +X / 0.2 m Inside No None
(−0.8, 0, 0) m −X / 1.0 m Outside No None

The two nominal 0.4 m hits read back as 0.399999976 m and 0.400000036 m in world coordinates. Their outward-normal dot direction was +1 on exit and −1 on entry. A diagonal interior query also hit, while the short exterior query, parallel exterior query and explicitly normalized (5, 0, 0) short query missed. No hit point or travel distance was assigned to a miss.

An independent audit derived all six face planes from the evaluated world vertices, classified each origin against those planes, and solved the first finite face intersection. A separate local slab calculation cross-checked travel. It checked the recorded API arguments, raw tuples, hit object, matrix, normals, face indices and CSV against that geometry. A 2 micrometre comparison allowance handles numerical readback; it is not a sensor or manufacturing accuracy claim. All original scenes and context were preserved. Zero direction, negative range and an origin on the boundary were explicitly uncalled, producing six null records rather than manufactured misses.

What Blender’s result actually supplies

The current Scene.ray_cast documentation describes a cast against evaluated geometry in world-space and returns the hit flag, point, normal, face index, object and matrix. Object.ray_cast instead accepts the ray in object-space and returns the hit flag, point, normal and face index. Both accept a maximum distance. Neither return signature contains an origin-inside field. The inspected RNA implementation also separates ray-direction normalization and first-hit data. That exact repository revision is source context; the executed engine identifies itself as 5.2.2 LTS, build d13f752e3b9c. Source inspection is not an assertion that this engine was built from that repository revision.

That coordinate distinction matters even for this simple test. The measured Blender box was translated to (1.2,−0.5,0.8) m in the world. A Scene call needs translated origins; an Object call uses the local coordinates from the reader example. The experiment normalized each direction explicitly, so the distance limit is ray travel in metres. It compared the two APIs against the same evaluated finite box, without treating source inspection as an executed API test.

For the known outward-oriented box, the exit-face normal points with the interior ray, whereas the entry-face normal points against the exterior ray. Preserve that returned normal instead of flipping it for presentation. Its crossing direction is useful evidence in this controlled shape; it is not a universal inside test for arbitrary meshes.

On a miss, do not promote a returned buffer value into a hit point. The experiment kept the raw tuple for inspection but used None for semantic hit location, normal and distance. A zero direction and a negative range are rejected before calling the APIs. An origin exactly on a face is kept as a separate boundary case and excluded from this comparison; no self-hit convention is assumed.

Keep containment independent

For this closed convex box, containment can be checked against all six outward face planes. The independent audit derived those planes from the actual world vertices, while a separate slab calculation checks the first intersection. An open surface, a concave mesh or an object’s bounding box requires a different treatment; the reader’s coordinate test is not a general mesh-occupancy routine.

Use the ray query when the next surface along a finite direction is the information you need. If a later decision depends on being inside a volume, establish that volume and its containment rule separately. A successful cast and an empty cast can each occur on either side of the boundary. When the next question is what a reported ray distance measures, the separate ray range versus camera depth study checks why off-axis travel differs from camera-axis Z. That distinction does not supply a containment test either.