Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/api-reference/layers/column-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ Whether to generate a line wireframe of the column. The outline will have
If `true`, the vertical surfaces of the columns use [flat shading](https://en.wikipedia.org/wiki/Shading#Flat_vs._smooth_shading).
If `false`, use smooth shading. Only effective if `extruded` is `true`.

#### `cap` (string, optional) {#cap}

* Default: `'flat'`

The shape of the cap at the top of each column. Only applies if `extruded: true`. Accepted values:

- `'flat'`: flat disk cap (default behavior, no additional geometry)
- `'dome'`: smooth hemispherical cap; normals transition from outward at the base to upward at the apex, giving a curved appearance under lighting
- `'cone'`: pointed conical cap

The height of the dome or cone cap equals the column radius.

#### `radiusUnits` (string, optional) {#radiusunits}

* Default: `'meters'`
Expand Down
137 changes: 109 additions & 28 deletions modules/layers/src/column-layer/column-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type ColumnGeometryProps = {
height?: number;
nradial?: number;
vertices?: number[];
cap?: 'flat' | 'dome' | 'cone';
};

export default class ColumnGeometry extends Geometry {
Expand All @@ -32,7 +33,7 @@ function tesselateColumn(props: ColumnGeometryProps): {
indices: Uint16Array;
attributes: Record<string, BinaryAttribute>;
} {
const {radius, height = 1, nradial = 10} = props;
const {radius, height = 1, nradial = 10, cap = 'flat'} = props;
let {vertices} = props;

if (vertices) {
Expand All @@ -43,9 +44,16 @@ function tesselateColumn(props: ColumnGeometryProps): {

const isExtruded = height > 0;
const vertsAroundEdge = nradial + 1; // loop
const useCap = isExtruded && cap !== 'flat';
// dome uses diskResolution/4 rings (min 2); cone uses 1 ring
const capSegs = useCap ? (cap === 'cone' ? 1 : Math.max(2, Math.round(nradial / 4))) : 0;

const numVertices = isExtruded
? vertsAroundEdge * 3 + 1 // top, side top edge, side bottom edge, one additional degenerage vertex
: nradial; // top
? useCap
? // sides (vertsAroundEdge*2 + 1 degenerate) + bridge (1) + capSegs ring strips (2*vertsAroundEdge each) + (capSegs-1) degenerates
vertsAroundEdge * 2 * (1 + capSegs) + capSegs + 1
: vertsAroundEdge * 3 + 1 // top, side top edge, side bottom edge, one additional degenerate vertex
: nradial; // top only (flat, not extruded)

const stepAngle = (Math.PI * 2) / nradial;

Expand Down Expand Up @@ -89,34 +97,107 @@ function tesselateColumn(props: ColumnGeometryProps): {
i += 3;
}

// The column geometry is rendered as a triangle strip, so
// in order to render sides and top in one go we need to use degenerate triangles.
// Duplicate last vertex of side trinagles and first vertex of the top cap to preserve winding order.

// top tesselation: 0, -1, 1, -2, 2, -3, 3, ...
//
// 0 -- 1
// / \
// -1 2
// | |
// -2 3
// \ /
// -3 -- 4
//
for (let j = isExtruded ? 0 : 1; j < vertsAroundEdge; j++) {
const v = Math.floor(j / 2) * Math.sign(0.5 - (j % 2));
const a = v * stepAngle;
const vertexIndex = (v + nradial) % nradial;
const sin = Math.sin(a);
const cos = Math.cos(a);

positions[i + 0] = vertices ? vertices[vertexIndex * 2] : cos * radius;
positions[i + 1] = vertices ? vertices[vertexIndex * 2 + 1] : sin * radius;
if (useCap) {
// Cap ring strips: rings sweep from the column's top edge (theta=0) up to the apex (theta=PI/2).
// Each ring pair (s, s+1) is interleaved as a triangle strip: ring_s[0], ring_{s+1}[0], ring_s[1], ...
// At the apex (s=capSegs), all ring vertices share the same point, producing valid cone-like triangles
// with degenerate fillers between them.

// Bridge vertex: one extra copy of ring0[0] to create a clean degenerate transition from the side strip.
const vx0 = vertices ? vertices[0] : radius;
const vy0 = vertices ? vertices[1] : 0;
const vLen0 = vertices ? Math.sqrt(vx0 * vx0 + vy0 * vy0) : radius;
positions[i + 0] = vx0;
positions[i + 1] = vy0;
positions[i + 2] = height / 2;
normals[i + 0] = vLen0 > 0 ? vx0 / vLen0 : 0;
normals[i + 1] = vLen0 > 0 ? vy0 / vLen0 : 0;
// normals[i + 2] = 0 (Float32Array default)
i += 3;

normals[i + 2] = 1;
for (let s = 0; s < capSegs; s++) {
const theta0 = (s / capSegs) * (Math.PI / 2);
const theta1 = ((s + 1) / capSegs) * (Math.PI / 2);
const r0 = radius * Math.cos(theta0);
const z0 = height / 2 + radius * Math.sin(theta0);
const r1 = radius * Math.cos(theta1);
const z1 = height / 2 + radius * Math.sin(theta1);
const nz0 = Math.sin(theta0);
const nScale0 = Math.cos(theta0);
const nz1 = Math.sin(theta1);
const nScale1 = Math.cos(theta1);

for (let j = 0; j <= nradial; j++) {
const a = j * stepAngle;
const vertexIndex = j % nradial;
const sinA = Math.sin(a);
const cosA = Math.cos(a);

const vx = vertices ? vertices[vertexIndex * 2] : cosA * radius;
const vy = vertices ? vertices[vertexIndex * 2 + 1] : sinA * radius;
const vLen = vertices ? Math.sqrt(vx * vx + vy * vy) : radius;
const invLen = vLen > 0 ? 1 / vLen : 0;

// ring_s vertex
positions[i + 0] = vx * (r0 / radius);
positions[i + 1] = vy * (r0 / radius);
positions[i + 2] = z0;
normals[i + 0] = vx * invLen * nScale0;
normals[i + 1] = vy * invLen * nScale0;
normals[i + 2] = nz0;
i += 3;

i += 3;
// ring_{s+1} vertex
positions[i + 0] = vx * (r1 / radius);
positions[i + 1] = vy * (r1 / radius);
positions[i + 2] = z1;
normals[i + 0] = vx * invLen * nScale1;
normals[i + 1] = vy * invLen * nScale1;
normals[i + 2] = nz1;
i += 3;
}

// Degenerate between ring strips (duplicate last vertex of this strip to bridge to next)
if (s < capSegs - 1) {
positions[i + 0] = positions[i - 3];
positions[i + 1] = positions[i - 2];
positions[i + 2] = positions[i - 1];
normals[i + 0] = normals[i - 3];
normals[i + 1] = normals[i - 2];
normals[i + 2] = normals[i - 1];
i += 3;
}
}
} else {
// The column geometry is rendered as a triangle strip, so
// in order to render sides and top in one go we need to use degenerate triangles.
// Duplicate last vertex of side triangles and first vertex of the top cap to preserve winding order.

// top tesselation: 0, -1, 1, -2, 2, -3, 3, ...
//
// 0 -- 1
// / \
// -1 2
// | |
// -2 3
// \ /
// -3 -- 4
//
for (let j = isExtruded ? 0 : 1; j < vertsAroundEdge; j++) {
const v = Math.floor(j / 2) * Math.sign(0.5 - (j % 2));
const a = v * stepAngle;
const vertexIndex = (v + nradial) % nradial;
const sin = Math.sin(a);
const cos = Math.cos(a);

positions[i + 0] = vertices ? vertices[vertexIndex * 2] : cos * radius;
positions[i + 1] = vertices ? vertices[vertexIndex * 2 + 1] : sin * radius;
positions[i + 2] = height / 2;

normals[i + 2] = 1;

i += 3;
}
}

if (isExtruded) {
Expand Down
26 changes: 22 additions & 4 deletions modules/layers/src/column-layer/column-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const defaultProps: DefaultProps<ColumnLayerProps> = {
stroked: false,
flatShading: false,

cap: 'flat',

getPosition: {type: 'accessor', value: (x: any) => x.position},
getFillColor: {type: 'accessor', value: DEFAULT_COLOR},
getLineColor: {type: 'accessor', value: DEFAULT_COLOR},
Expand Down Expand Up @@ -136,6 +138,15 @@ type _ColumnLayerProps<DataT> = {
*/
flatShading?: boolean;

/**
* The shape of the cap at the top of each column. Only applies if `extruded: true`.
* - `'flat'`: flat disk cap (default, no additional geometry)
* - `'dome'`: smooth hemispherical cap with normals curving from outward to upward
* - `'cone'`: pointed conical cap
* @default 'flat'
*/
cap?: 'flat' | 'dome' | 'cone';

/**
* The units of the radius.
* @default 'meters'
Expand Down Expand Up @@ -306,18 +317,25 @@ export default class ColumnLayer<DataT = any, ExtraPropsT extends {} = {}> exten
regenerateModels ||
props.diskResolution !== oldProps.diskResolution ||
props.vertices !== oldProps.vertices ||
props.cap !== oldProps.cap ||
(props.extruded || props.stroked) !== (oldProps.extruded || oldProps.stroked)
) {
this._updateGeometry(props);
}
}

getGeometry(diskResolution: number, vertices: number[] | undefined, hasThinkness: boolean) {
getGeometry(
diskResolution: number,
vertices: number[] | undefined,
hasThinkness: boolean,
cap?: string
) {
const geometry = new ColumnGeometry({
radius: 1,
height: hasThinkness ? 2 : 0,
vertices,
nradial: diskResolution
nradial: diskResolution,
cap: cap as 'flat' | 'dome' | 'cone'
});

let meanVertexDistance = 0;
Expand Down Expand Up @@ -361,8 +379,8 @@ export default class ColumnLayer<DataT = any, ExtraPropsT extends {} = {}> exten
};
}

protected _updateGeometry({diskResolution, vertices, extruded, stroked}) {
const geometry = this.getGeometry(diskResolution, vertices, extruded || stroked);
protected _updateGeometry({diskResolution, vertices, extruded, stroked, cap}) {
const geometry = this.getGeometry(diskResolution, vertices, extruded || stroked, cap);

this.setState({
fillVertexCount: geometry.attributes.POSITION.value.length / 3
Expand Down
74 changes: 74 additions & 0 deletions test/modules/layers/column-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,77 @@ test('ColumnGeometry#tesselation', () => {
-1, 0, 0
]), 'positions generated').toBeTruthy();
});

test('ColumnGeometry#cap', t => {
const nradial = 4;
const vertsAroundEdge = nradial + 1;

t.comment('cone cap');
// capSegs = 1 for cone
const capSegs_cone = 1;
const expectedConeVerts = vertsAroundEdge * 2 * (1 + capSegs_cone) + capSegs_cone + 1;
let geometry = new ColumnGeometry({radius: 1, height: 1, nradial, cap: 'cone'});
let attributes = geometry.getAttributes();

t.is(
attributes.POSITION.value.length,
expectedConeVerts * 3,
'cone cap POSITION has correct size'
);
t.is(
attributes.NORMAL.value.length,
expectedConeVerts * 3,
'cone cap NORMAL has correct size'
);
// wireframe indices unchanged (covers sides only)
t.is(attributes.indices.value.length, nradial * 3 * 2, 'cone cap indices has correct size');

// Verify apex vertices (ring_1 = theta=PI/2) have z = height/2 + radius = 1/2 + 1 = 1.5
// and position (0, 0, 1.5) (radius * cos(PI/2) = 0)
const apexZ = 1 / 2 + 1; // 1.5
let foundApex = false;
for (let n = 0; n < expectedConeVerts; n++) {
const x = attributes.POSITION.value[n * 3];
const y = attributes.POSITION.value[n * 3 + 1];
const z = attributes.POSITION.value[n * 3 + 2];
if (Math.abs(x) < 1e-6 && Math.abs(y) < 1e-6 && Math.abs(z - apexZ) < 1e-6) {
foundApex = true;
}
}
t.ok(foundApex, 'cone cap has apex vertex at (0, 0, height/2 + radius)');

t.comment('dome cap');
// capSegs = max(2, round(nradial/4)) for dome; nradial=4 → max(2,1) = 2
const capSegs_dome = Math.max(2, Math.round(nradial / 4));
const expectedDomeVerts = vertsAroundEdge * 2 * (1 + capSegs_dome) + capSegs_dome + 1;
geometry = new ColumnGeometry({radius: 1, height: 1, nradial, cap: 'dome'});
attributes = geometry.getAttributes();

t.is(
attributes.POSITION.value.length,
expectedDomeVerts * 3,
'dome cap POSITION has correct size'
);

// Verify side geometry is preserved (first 8 side vertices unchanged from flat)
const flatGeometry = new ColumnGeometry({radius: 1, height: 1, nradial});
const flatAttributes = flatGeometry.getAttributes();
t.ok(
equals(
attributes.POSITION.value.slice(0, 3 * 8),
flatAttributes.POSITION.value.slice(0, 3 * 8)
),
'dome cap: side positions match flat'
);

t.comment('cap prop ignored when not extruded');
geometry = new ColumnGeometry({radius: 1, height: 0, nradial, cap: 'dome'});
attributes = geometry.getAttributes();
t.is(
attributes.POSITION.value.length,
nradial * 3,
'cap ignored when height=0 (not extruded)'
);

t.end();
});
Loading