THE SCHEDULE FOR AN OBJECT-GEOMETRY HORIZON SWEEP.
A sensor bolted to a mast does not see a clean horizon either, and the thing
in its way is not terrain — it is the platform the sensor is standing on. A
radar on a ship's foremast is blanked aft by its own funnels, hangar and
after mast; STK draws that as a green surface carved out of the sensor volume
and refuses access through it ("Constraining Access Based on Visibility").
TerrainHorizonSweep answers the same question for terrain by walking
RANGE along each azimuth and reading heights. That schedule is wrong for an
object. Terrain is a height field sampled at a position; a superstructure is
a closed solid that a ray either hits or misses, and the sampling primitive
available for it is a pick, not a height read. So this module walks
ELEVATION, not range:
THE MASK IS A THRESHOLD IN ELEVATION. Along one azimuth, a ray fired at
high elevation clears the structure and a ray fired at low elevation hits
it. Because the platform is a solid attached below/around the mount, that
transition is what the mask angle IS. The sweep therefore fires a coarse
descending ELEVATION LADDER until the first hit, then BISECTS the bracket
between the last miss and that first hit. Each halving buys one bit, so the
cost is `ladder + ceil(log2(step / tolerance))` picks per azimuth instead of
the hundreds a uniform elevation scan at the same tolerance would need —
and picks are expensive (each one is an offscreen render).
AZIMUTHS TERMINATE ON THE BOUNDING SPHERE. The platform is entirely inside
one sphere. Nothing outside that sphere is the platform, so:
reach = |center - mount| + radius (no hit is possible past it)
topRise = (center.up - mount.up) + radius (nothing can be higher)
bound(d) = atan(topRise / d), d <= reach; -infinity beyond
Once that bound has fallen to the mask already accumulated for an azimuth,
no further sample on it can matter. Unlike the terrain sweep — whose Everest
ceiling keeps open-ocean azimuths alive for hundreds of kilometres — an
object's bound collapses within metres, which is exactly why the sweep is
affordable at pick prices.
Everything here is PURE ARITHMETIC. Nothing in this module touches a Scene, a
Model or a Ray: a caller supplies hit/miss verdicts from whatever native
sampler it has (`Scene.pickFromRay` is the one OrbPro ships), and this module
decides where to sample next, when to stop, and how the resulting table
becomes sensor geometry. That separation is what lets the guarantees be
asserted without a GL context.
The output is deliberately the SAME elevation-by-azimuth table
TerrainHorizonSweep feeds: one elevation per azimuth bin, azimuth
measured from north and increasing with the index, elevation in radians and
positive above the mount's horizontal. ObjectHorizonSweep.directions
converts it to the custom-sensor boundary, so an object mask and a terrain
mask are interchangeable inputs to a `Sensor` of `Type.CUSTOM`.Members
Default number of azimuth bins. 72 bins is a 5-degree step: fine enough to
resolve a mast from the gap beside it, coarse enough that the whole sweep is
a few hundred picks rather than a few thousand.
Default coarse ladder step, in degrees.
Default top of the elevation ladder, in degrees. A structure can be directly
overhead of a mount tucked under it, so the ladder starts near the zenith.
Default widest azimuth span of a single sector, in radians. A convex cone
cannot span half the sky, so sectors are split well inside 180 degrees.
Default bottom of the elevation ladder, in degrees. Below the mount's own
horizontal the platform is not masking sky, it is simply beneath the sensor.
Default ceiling for a containment sector, in radians — just short of the
zenith so a sector is a proper spherical quadrilateral rather than a
degenerate point.
Default bisection tolerance, in degrees — the accuracy of a mask edge.
Methods
Azimuth bin index -> azimuth from north, in RADIANS. Bin 0 is due north and
the index increases clockwise, which is the convention the mask table and
every az/el readout in the gallery share.
| Name | Type | Default | Description |
|---|---|---|---|
index |
number | The bin index. | |
azimuthCount |
number |
ObjectHorizonSweep.DEFAULT_AZIMUTH_COUNT
|
optional Azimuth bins. |
Returns:
Azimuth from north, in radians.
static Cesium.ObjectHorizonSweep.directions(mask, options) → Array.<Spherical>
Mask table -> custom-sensor boundary directions.
The sensor's local frame is the mount's east-north-up frame (x=east,
y=north, z=up). `Spherical.clock` is measured in the xy-plane from +x, so an
azimuth-from-north converts as `clock = atan2(cos(az), sin(az))`, and `cone`
is measured from zenith, so `cone = 90 deg - elevation`.
The walk is DESCENDING in azimuth, which is ASCENDING in clock — the engine's
own CUSTOM winding. The reverse order produces a boundary that renders
plausibly but inverts containment, so the drawn volume would stop being the
access authority. This is the same convention `maskToDirections` uses in the
terrain and az-el mask demos; it lives here so it is asserted once rather
than re-derived per demo.
| Name | Type | Description | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
mask |
Float64Array | Array.<number> | One elevation per azimuth bin, in radians. | ||||||||
options |
object |
optional
Options.
|
Returns:
The boundary directions, in CUSTOM winding order.
The highest elevation angle any part of the platform at or beyond `distance`
could still occupy, in DEGREES, from the bounding sphere alone. Negative
infinity once `distance` is past the sphere's reach — no geometry remains.
| Name | Type | Description | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
distance |
number | Distance from the mount, in meters. | ||||||||||||||||
options |
object |
optional
Options.
|
Returns:
The bound, in degrees.
The coarse elevation ladder, in DEGREES, ordered from the top DOWN.
Descending order is not cosmetic: the sweep wants the FIRST hit walking down
from open sky, because that hit and the miss above it bracket the mask edge.
Walking up from below would bracket the far side of the structure instead.
| Name | Type | Description | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
options |
object |
optional
Options.
|
Returns:
Elevations in degrees, descending.
Whether an azimuth is EXHAUSTED at `distance`: the mask already accumulated
for it is at least as high as anything further out could reach.
| Name | Type | Description |
|---|---|---|
maskDegrees |
number | The highest elevation seen on this azimuth so far, in degrees. |
distance |
number | The distance just sampled, in meters. |
options |
object |
optional
Options, as ObjectHorizonSweep.elevationBound. |
Returns:
True when no further sample on this azimuth can raise the mask.
Reduce hit/miss samples to one mask elevation per azimuth bin.
The mask at an azimuth is the HIGHEST elevation at which a ray still struck
the platform: above it the sensor sees sky, at and below it the sensor is
looking into its own superstructure. Azimuths with no hit at all — clear
arcs, which every real mask has — take `floorRadians`, NOT negative infinity:
a missing answer left at the floor of the search degenerates that direction
into a half-space and inverts the sensor volume (the failure
TerrainHorizonSweep's callers hit in 2026-08-14).
| Name | Type | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
samples |
Array.<object> | Samples, each `{ azimuthIndex, elevation, hit }` with `elevation` in RADIANS. | ||||||||||||
options |
object |
optional
Options.
|
Returns:
One elevation per bin, in radians, indexed by azimuth bin.
The distance past which no part of the platform can lie — the hard stop for
every azimuth, independent of what the sweep has measured.
| Name | Type | Description |
|---|---|---|
options |
object |
optional
Options, as ObjectHorizonSweep.elevationBound. |
Returns:
The reach, in meters.
How many bisection steps are needed to pin a mask edge inside `tolerance`
once the ladder has bracketed it within `ladderStep`.
| Name | Type | Default | Description |
|---|---|---|---|
ladderStep |
number |
ObjectHorizonSweep.DEFAULT_LADDER_STEP
|
optional The bracket width, in degrees. |
tolerance |
number |
ObjectHorizonSweep.DEFAULT_TOLERANCE
|
optional Required accuracy, in degrees. |
Returns:
The number of additional samples per azimuth.
The total pick budget for a sweep — what a caller must be willing to pay
before it starts, since every sample is an offscreen render.
| Name | Type | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
options |
object |
optional
Options, as ObjectHorizonSweep.elevationLadder plus:
|
Returns:
An upper bound on the number of samples.
Decompose a mask into CONVEX containment sectors.
WHY THIS EXISTS. A custom sensor's drawn boundary is a general polygon on the
sphere, but its CONTAINMENT is evaluated as the intersection of one inward
half-space per adjacent direction pair — that is, the CONVEX HULL cone of the
directions (Atlas 2026-08-15, `Detection/SDF.cpp` `CustomSDF`). An az/el mask
carved by real structure is emphatically NOT convex: every notch contributes a
plane that slices the entire volume rather than its own azimuth sector, and
the admitted region collapses to a narrow wedge. The picture stays right — the
renderer does not use those planes — while access verdicts silently become
wrong, which is the worst possible failure for a mask whose entire purpose is
to be the access authority.
The fix is decomposition. The mask table is piecewise constant by
construction: one elevation per azimuth BIN. So the masked-in sky is exactly
the union of spherical quadrilaterals — one per run of bins sharing an
elevation, bounded below by that elevation, above by `topElevation`, and on
the sides by the run's azimuth edges. Each quad IS convex while its azimuth
span stays under 180 degrees, so each is a faithful custom-sensor volume, and
a target has access iff it is inside ANY of them.
Sectors are returned in ascending-clock winding, the same convention
ObjectHorizonSweep.directions uses.
| Name | Type | Description | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
mask |
Float64Array | Array.<number> | One elevation per azimuth bin, in radians. | ||||||||||||||||
options |
object |
optional
Options.
|
Returns:
Sectors, each `{ startAzimuth, endAzimuth, elevation, directions }`.