diff --git a/docs/api-reference/layers/column-layer.md b/docs/api-reference/layers/column-layer.md index 02fac70dc4a..532f18dfbcd 100644 --- a/docs/api-reference/layers/column-layer.md +++ b/docs/api-reference/layers/column-layer.md @@ -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'` diff --git a/modules/layers/src/column-layer/column-geometry.ts b/modules/layers/src/column-layer/column-geometry.ts index e48a918744f..3b6cd3ae5d0 100644 --- a/modules/layers/src/column-layer/column-geometry.ts +++ b/modules/layers/src/column-layer/column-geometry.ts @@ -13,6 +13,7 @@ type ColumnGeometryProps = { height?: number; nradial?: number; vertices?: number[]; + cap?: 'flat' | 'dome' | 'cone'; }; export default class ColumnGeometry extends Geometry { @@ -32,7 +33,7 @@ function tesselateColumn(props: ColumnGeometryProps): { indices: Uint16Array; attributes: Record; } { - const {radius, height = 1, nradial = 10} = props; + const {radius, height = 1, nradial = 10, cap = 'flat'} = props; let {vertices} = props; if (vertices) { @@ -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; @@ -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) { diff --git a/modules/layers/src/column-layer/column-layer.ts b/modules/layers/src/column-layer/column-layer.ts index aa3b0430893..2274c895e83 100644 --- a/modules/layers/src/column-layer/column-layer.ts +++ b/modules/layers/src/column-layer/column-layer.ts @@ -49,6 +49,8 @@ const defaultProps: DefaultProps = { 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}, @@ -136,6 +138,15 @@ type _ColumnLayerProps = { */ 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' @@ -306,18 +317,25 @@ export default class ColumnLayer 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; @@ -361,8 +379,8 @@ export default class ColumnLayer 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 diff --git a/test/modules/layers/column-geometry.spec.ts b/test/modules/layers/column-geometry.spec.ts index 433a1a80427..27ee2503862 100644 --- a/test/modules/layers/column-geometry.spec.ts +++ b/test/modules/layers/column-geometry.spec.ts @@ -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(); +});