Clamping Polylines to Terrain and Buildings
This page draws linear features over tiled 3D content in CesiumJS so they stay visible and correctly placed — clamping routes and boundaries onto terrain and buildings with ground polylines, choosing the arc type for long lines, styling with widths in pixels and dashes, handling genuinely aerial lines such as cables with a depth-fail material and a catenary, and verifying placement against sampled terrain in EPSG:4979.
Why you hit this
A line is the hardest overlay to place. A polygon draped on terrain looks right whatever the terrain does; a line drawn from surveyed coordinates disappears into the ground on one side of a ridge and floats a metre above it on the other, because the terrain tile in view is an approximation whose error changes with the level of detail. The usual reaction — add a metre to every height — swaps one artefact for another, and neither survives the camera moving closer. Cesium has a purpose-built answer, and the remaining work is knowing when not to clamp. The strategy context is in vector overlays on 3D Tiles.
Prerequisites
- CesiumJS 1.110+ with terrain loaded and depth-texture support (
scene.groundPrimitiveSupported). - Python 3.10+ with
geopandas>=0.14,shapely>=2.0andpyproj>=3.6for preparing the lines. - Line data with a declared CRS; the examples use an inspection route and a cable span digitised in EPSG:25832.
Step-by-Step
1. Prepare and densify the lines
import geopandas as gpd
import numpy as np
from shapely.geometry import LineString
routes = gpd.read_file("network.gpkg", layer="inspection_routes").to_crs(25832)
def densify(line, max_spacing=25.0):
"""Insert vertices so no segment exceeds max_spacing, in CRS units."""
coords = list(line.coords)
out = [coords[0]]
for a, b in zip(coords, coords[1:]):
d = np.hypot(b[0] - a[0], b[1] - a[1])
n = max(1, int(np.ceil(d / max_spacing)))
for k in range(1, n + 1):
out.append((a[0] + (b[0] - a[0]) * k / n, a[1] + (b[1] - a[1]) * k / n))
return LineString(out)
routes["geometry"] = routes.geometry.apply(densify)
routes.to_crs(4326)[["route_id", "status", "geometry"]].to_file("web/routes_4326.geojson", driver="GeoJSON")
print(f"{len(routes)} routes, {int(routes.geometry.apply(lambda g: len(g.coords)).sum()):,} vertices")
Densifying looks unnecessary — a clamped line follows the terrain regardless — and it matters for a different reason: a clamped polyline is subdivided along the ground, but its horizontal path between two far-apart vertices is a straight line in the chosen arc type, not along the road it was digitised from. A 25 m spacing keeps a route on its road over a curve without exploding the vertex count.
2. Clamp with a batched ground polyline primitive
async function clampedRoutes(url, scene, colorFor) {
const fc = await (await fetch(url)).json();
const instances = fc.features.map((f) => new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions: Cesium.Cartesian3.fromDegreesArray(f.geometry.coordinates.flat()),
width: 6.0, // pixels, not metres
arcType: Cesium.ArcType.GEODESIC,
granularity: Cesium.Math.RADIANS_PER_DEGREE * 0.05,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(colorFor(f.properties)),
},
id: f.properties.route_id,
}));
const primitive = new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
classificationType: Cesium.ClassificationType.BOTH,
appearance: new Cesium.PolylineColorAppearance(),
asynchronous: true,
});
scene.primitives.add(primitive);
await primitive.readyPromise;
return primitive;
}
const statusColor = {
due: Cesium.Color.fromCssColorString("#c46a3d"),
done: Cesium.Color.fromCssColorString("#4f7a4d"),
blocked: Cesium.Color.fromCssColorString("#b0413e"),
};
const routes = await clampedRoutes("/web/routes_4326.geojson", viewer.scene,
(p) => statusColor[p.status] ?? Cesium.Color.fromCssColorString("#1f6b8a"));
Width is in pixels, which is the right unit for a line that means “the route”, because it stays readable at every zoom. A line that represents a physical width — a 3.5 m carriageway — is not a polyline at all; it is a draped polygon. classificationType: BOTH lets the route climb onto a building where it crosses one, which is what an inspection route through a site should do.
3. Choose the arc type deliberately
// Two vertices 40 km apart, drawn three ways
const a = [11.1, 48.0], b = [11.9, 48.3];
for (const [name, arcType] of [["GEODESIC", Cesium.ArcType.GEODESIC],
["RHUMB", Cesium.ArcType.RHUMB],
["NONE", Cesium.ArcType.NONE]]) {
viewer.entities.add({
name,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([...a, ...b]),
width: 3,
clampToGround: true,
arcType,
material: Cesium.Color.fromCssColorString(name === "GEODESIC" ? "#1f6b8a" : name === "RHUMB" ? "#c46a3d" : "#b0413e"),
},
});
}
GEODESIC follows the shortest path on the ellipsoid and is right for anything derived from survey or GIS data at city scale. RHUMB holds a constant bearing, which matters for marine and aviation lines. NONE connects the two positions with a straight line through space, which for two points 40 km apart passes about 30 m below the surface — invisible when clamped, and wrong as soon as the line is used for measurement. At the segment lengths of a densified route the three agree to millimetres, which is another reason densifying early removes a class of question.
4. Style with dashes, and keep width honest
viewer.entities.add({
name: "planned diversion",
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(diversionLonLat),
width: 5,
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString("#c46a3d"),
dashLength: 20.0,
}),
},
});
Dashes read as “planned” or “provisional” without a legend, which makes them worth the extra material. Keep the vocabulary small and consistent across a twin — solid for as-built, dashed for planned, dotted for inferred — because a viewer with six line styles teaches nobody anything.
5. Do not clamp what is genuinely in the air
A power line, a pipe bridge or a crane path has real geometry above the ground, and clamping it would be a lie. Those lines need their own heights, a depth-fail material so they remain visible where terrain hides them, and a sag curve if they are suspended.
function catenary(startLonLatH, endLonLatH, sagMetres, samples = 48) {
const positions = [];
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const lon = startLonLatH[0] + (endLonLatH[0] - startLonLatH[0]) * t;
const lat = startLonLatH[1] + (endLonLatH[1] - startLonLatH[1]) * t;
const straight = startLonLatH[2] + (endLonLatH[2] - startLonLatH[2]) * t;
const sag = sagMetres * 4 * t * (1 - t); // parabolic approximation of a catenary
positions.push(Cesium.Cartesian3.fromDegrees(lon, lat, straight - sag));
}
return positions;
}
viewer.entities.add({
name: "110 kV span",
polyline: {
positions: catenary([11.5701, 48.1362, 512.4], [11.5768, 48.1379, 514.1], 6.2),
width: 3,
clampToGround: false,
material: Cesium.Color.fromCssColorString("#1f2937"),
depthFailMaterial: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString("#5b6471"),
dashLength: 12.0,
}),
},
});
The parabola is the standard small-sag approximation of a catenary and is accurate to a few centimetres for spans under a few hundred metres — well inside the accuracy of the sag figure itself, which comes from the line’s design tension and temperature. depthFailMaterial is the detail that makes an aerial line usable: where a building or hill is in front of the span, the dashed grey material draws instead of nothing, so the user sees the line continue behind the obstruction.
6. Verify placement against sampled terrain
const carto = routeLonLat.reduce((acc, v, i) => (i % 2 ? acc : [...acc, Cesium.Cartographic.fromDegrees(routeLonLat[i], routeLonLat[i + 1])]), []);
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, carto);
const heights = sampled.map((c) => c.height);
console.log(`route terrain height: min ${Math.min(...heights).toFixed(1)} m, max ${Math.max(...heights).toFixed(1)} m`);
const picked = viewer.scene.pick(Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene, Cesium.Cartesian3.fromDegrees(routeLonLat[0], routeLonLat[1])));
console.log("picked id at the route start:", picked && picked.id);
sampleTerrainMostDetailed gives the heights the route actually runs over, which is the number a report needs — the length of a clamped line along the ground, the maximum gradient, the height range. Picking at a known vertex confirms the identifier survived batching.
Expected Output & Verification
84 routes, 12,908 vertices
route terrain height: min 508.2 m, max 547.9 m
picked id at the route start: RT-0042
Verify clamping visually at two levels of detail: fly in until the terrain refines twice and confirm the line does not detach. A line that separates from the surface as tiles refine was not clamped — it was drawn with perPositionHeight or with clampToGround on a primitive type that ignores it.
For length, measure along the sampled heights rather than in plan:
let flat = 0, slope = 0;
for (let i = 1; i < sampled.length; i++) {
const a = Cesium.Cartographic.toCartesian(sampled[i - 1]);
const b = Cesium.Cartographic.toCartesian(sampled[i]);
slope += Cesium.Cartesian3.distance(a, b);
flat += Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromRadians(sampled[i - 1].longitude, sampled[i - 1].latitude, 0),
Cesium.Cartesian3.fromRadians(sampled[i].longitude, sampled[i].latitude, 0));
}
console.log(`plan length ${flat.toFixed(0)} m, along-ground length ${slope.toFixed(0)} m`);
Performance Notes
- Batch, as with polygons. One
GroundPolylinePrimitivewith a hundred routes is one draw call; a hundred entity polylines are a hundred. - Granularity controls subdivision. A coarse granularity on long segments makes a clamped line cut corners across terrain; a very fine one multiplies vertices. The default is fine for city-scale work once lines are densified to 25 m.
- Dash materials cost a separate appearance, so a layer with three dash styles is three primitives. Group by style, not by feature.
- Aerial spans are cheap — 48 positions each — but each entity is its own primitive; for a transmission network with thousands of spans, build them into one
PolylineCollection.
Common Errors
The line is invisible over buildings but fine over terrain. classificationType is TERRAIN. Set BOTH for anything that should climb onto tile content.
A long line bulges away from the road it follows. Two distant vertices connected with ArcType.NONE or a coarse granularity. Densify the line and use GEODESIC.
width appears to do nothing beyond about 10. Browsers cap hardware line width; Cesium’s ground polylines are rendered as geometry and handle larger widths, but entity polylines on some platforms do not. Use the ground polyline path when a thick line matters.
An aerial line flickers against a building. Z-fighting between the span and the facade it grazes. Nudge the span’s height by its real clearance rather than an arbitrary offset, and keep the depth-fail material so the hidden part still reads.
Frequently Asked Questions
Can a clamped line be picked reliably?
Yes, by instance id as in step 2, and the pick tolerance is the drawn pixel width — a 6 px line is easy to hit. For touch interfaces, draw an invisible wider line behind it for picking, or increase the width on small screens.
How do I show direction along a route?
A polyline arrow material for a single route, or a repeating dash offset animated over time for a flow. Both cost a material per style, so use them for the few routes where direction matters rather than for a whole network.
Do clamped lines work without terrain?
Yes — with the ellipsoid as the surface, they clamp to it, and with CESIUM_3D_TILE they clamp to tile content. A line over a flat ellipsoid and a tileset of buildings is a valid and common configuration for a site twin.
Related Guides
- Draping GeoJSON Polygons on 3D Tiles — the polygon equivalent, including batching
- Classifying 3D Tiles with Polygon Volumes — colouring the buildings a route passes
- Tracking Down Z-Fighting Between Terrain and Buildings — when an aerial line grazes geometry
Back to Vector Overlays on 3D Tiles.