class_name SphereSweep extends RefCounted
## Shared sphere sweep utility. Wraps cast_motion + get_rest_info with
## Overgrowth-style sliding sphere refinement for accurate surface normals.
## Caches SphereShape3D by radius to avoid per-call allocations.
##
## WHY THERE IS A NUDGE, AND WHY THE FALLBACK CHANGED (2026-08-31).
##
## cast_motion returns [safe, unsafe]; the unsafe fraction puts the sphere at
## TANGENCY with the surface -- touching, with zero overlap. get_rest_info at
## exact tangency is a knife edge, and a pose is only representable to within one
## float32 ulp of its coordinate. High ALTITUDE is what breaks it: at y = 9700 m
## an ulp is 0.00097 m, the tangent pose lands on the separated side, and
## get_rest_info returns EMPTY. Horizontal distance does not do this -- measured
## clean at 100 km in X.
##
## The old fallback then returned a point 2r ABOVE the surface (a sign error:
## `contact_pos - motion.normalized()*radius` moves BACK along the sweep, away
## from the thing that was hit) and a normal of `-motion.normalized()`, i.e.
## straight up for a downward sweep. FootIK therefore lifted every foot 0.1 m --
## five times its own GROUND_OFFSET -- and never tilted a foot to the ground.
## WallClimb and player_procedural._detect_walls read the same invented normal.
##
## Gate: scenes/test/large_coord_ik_probe.gd (sloped ground, because on FLAT
## ground the invented straight-up normal is accidentally correct and a flat
## gate would certify it).
static var _shape_cache: Dictionary = {} ## radius → SphereShape3D
static var _params: PhysicsShapeQueryParameters3D = null
## Every sweep() ever made in this process. Callers that want THEIR share diff it around
## their own work (SimStats.add, hyper-games #114 §7) — feet, crawlers and hoppers
## all come through here.
static var calls: int = 0
## Sweep a sphere from `from` to `to`. Returns { "point", "normal", "fraction" } or empty.
static func sweep(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3,
radius: float, exclude: Array[RID] = [], collision_mask: int = 0xFFFFFFFF) -> Dictionary:
calls += 1
var motion: Vector3 = to - from
# Cache shapes by radius (snapped to 3 decimals to avoid unbounded growth)
var key: float = snappedf(radius, 0.001)
var shape: SphereShape3D
if _shape_cache.has(key):
shape = _shape_cache[key]
else:
shape = SphereShape3D.new()
shape.radius = key
_shape_cache[key] = shape
# Reuse params object
if _params == null:
_params = PhysicsShapeQueryParameters3D.new()
_params.shape = shape
_params.transform = Transform3D(Basis.IDENTITY, from)
_params.motion = motion
_params.collision_mask = collision_mask
if not exclude.is_empty():
_params.exclude = exclude
else:
_params.exclude = []
var fractions: PackedFloat32Array = space.cast_motion(_params)
if fractions.is_empty() or fractions[1] >= 1.0:
return {}
var contact_t: float = fractions[1]
var contact_pos: Vector3 = from + motion * contact_t
var dir: Vector3 = motion.normalized()
var nudge: float = _nudge(from, radius)
# Try the tangent pose first, so anywhere this already worked is untouched.
# Only when it comes back empty do we push the sphere into the surface far
# enough that the overlap survives the coordinate's own quantisation.
_params.transform = Transform3D(Basis.IDENTITY, contact_pos)
var rest: Dictionary = space.get_rest_info(_params)
if rest.is_empty():
_params.transform = Transform3D(Basis.IDENTITY, contact_pos + dir * nudge)
rest = space.get_rest_info(_params)
if rest.is_empty():
# Last resort. The surface is one radius FORWARD along the sweep from the
# tangent centre, not backward. The normal here is a guess -- the reverse
# of the sweep direction -- and callers that care about surface tilt
# (FootIK, WallClimb) are being told something invented, so this branch
# should stay rare rather than become the normal path.
return {
"point": contact_pos + dir * radius,
"normal": -dir,
"fraction": contact_t
}
var hit_point: Vector3 = rest["point"]
var hit_normal: Vector3 = rest["normal"]
# Sliding sphere refinement for accurate surface normal. Seat the sphere just
# INSIDE the surface for the same reason as above: `hit_point + normal*radius`
# is the tangent pose again, and at altitude it returns empty and the
# refinement silently does nothing.
var slide_pos: Vector3 = hit_point + hit_normal * (radius - nudge)
_params.transform = Transform3D(Basis.IDENTITY, slide_pos)
var slide_rest: Dictionary = space.get_rest_info(_params)
if not slide_rest.is_empty():
hit_normal = slide_rest["normal"]
return {
"point": hit_point,
"normal": hit_normal,
"fraction": contact_t
}
## How far to push the sphere past tangency. Scaled to the coordinate, because
## that is what sets the representable step: float32 has a 24-bit significand, so
## the spacing at |p| is |p| * 2^-23. Four of those clears the knife edge.
## Floored so it is never zero near the origin, and capped at half the radius so
## the sphere cannot be pushed clean through a thin feature.
static func _nudge(at: Vector3, radius: float) -> float:
var scale: float = maxf(maxf(absf(at.x), absf(at.y)), absf(at.z))
var ulp: float = scale * 1.1920929e-7
return clampf(ulp * 4.0, radius * 0.02, radius * 0.5)