Skip to content

Commit 7bb6cd5

Browse files
authored
Add prism and ring geometries and capped torus segments (#250)
1 parent 4fa5f54 commit 7bb6cd5

16 files changed

Lines changed: 725 additions & 19 deletions

File tree

‎docs/modules/geometry/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ Pass these CPU buffers to the renderer of your choice. The module does not creat
4444
the glTF shape conventions.
4545
- `CubeGeometry`, `ConeGeometry`, `TruncatedConeGeometry`, `IcoSphereGeometry` provide additional
4646
common tessellators.
47+
- `PrismGeometry` provides closed flat-sided polygons, including hexagonal prisms;
48+
`RingGeometry` provides solid annuli and capped ring segments.
4749
4850
An infinite glTF plane cannot be tessellated, so `PlaneGeometry` requires finite `sizeX` and `sizeZ`
4951
values. Use `PlaneShape` from `@math.gl/culling` when infinite or partially infinite analytic
@@ -60,6 +62,8 @@ planes are required.
6062
- [PlaneGeometry](./api-reference/plane-geometry.md)
6163
- [SphereGeometry](./api-reference/sphere-geometry.md)
6264
- [IcoSphereGeometry](./api-reference/ico-sphere-geometry.md)
65+
- [PrismGeometry](./api-reference/prism-geometry.md)
66+
- [RingGeometry](./api-reference/ring-geometry.md)
6367
- [Geometry](./api-reference/geometry.md)
6468
6569
## Parametric surfaces

‎docs/modules/geometry/api-reference/parametric.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,21 @@ Defaults: `majorRadius: 1`, `minorRadius: 0.3`, `majorSegments: 48`,
2626
`minorSegments: 24`, `arc: 2π`. Both radii must be positive and the minor radius
2727
must be smaller than the major radius. At least three segments per axis are
2828
required. The torus revolves around Y, with outward analytic normals. Arc is in
29-
(0, 2π]; partial sweeps have open ends.
29+
(0, 2π]; partial sweeps have open ends by default. `startAngle` (default `0`) rotates
30+
the segment around Y, using the same angular direction as the existing surface.
31+
Set `cap: true` to close partial segments with planar end disks and separate flat
32+
normals. Full tori have no end faces, regardless of `cap`.
33+
34+
```typescript
35+
const segment = new TorusGeometry({
36+
majorRadius: 1, minorRadius: 0.25,
37+
startAngle: Math.PI / 4, arc: Math.PI * 1.5, cap: true
38+
});
39+
```
40+
41+
Use the gallery's sweep, start-angle and **Close segment ends** controls to compare
42+
open and closed segments. The function editor describes the curved surface; caps
43+
are separate geometry.
3044

3145
## LatheGeometry
3246

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# PrismGeometry
2+
3+
import GeometryViewer from '@site/src/components/geometry-viewer';
4+
5+
<GeometryViewer geometryType="PrismGeometry" inline />
6+
7+
A closed regular polygon prism centered at the origin, with height along Y. Each
8+
side has a constant outward normal, and the top and bottom use separate flat
9+
normals. Unlike a low-resolution cylinder, its side edges remain sharp.
10+
11+
```typescript
12+
import {PrismGeometry} from '@math.gl/geometry';
13+
14+
const hexagon = new PrismGeometry({radius: 0.6, height: 1.5, sides: 6});
15+
```
16+
17+
| Property | Default | Meaning |
18+
| --- | --- | --- |
19+
| `radius` | `1` | Positive distance from the axis to a polygon corner. |
20+
| `height` | `1` | Positive total height; caps lie at ±height/2. |
21+
| `sides` | `6` | Integer polygon side count, at least three. |
22+
| `startAngle` | `0` | Finite angle in radians placing the first corner around Y. |
23+
24+
Angles begin on +X and rotate toward −Z. The mesh is indexed with `triangle-list`
25+
topology and Float32 `POSITION`, `NORMAL` and `TEXCOORD_0` attributes. It accepts
26+
`id` and caller-supplied attribute overrides like the other primitives.
27+
See [Geometry](./geometry.md) for the CPU container API.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# RingGeometry
2+
3+
import GeometryViewer from '@site/src/components/geometry-viewer';
4+
5+
<GeometryViewer geometryType="RingGeometry" inline />
6+
7+
A solid annulus centered at the origin, with height along Y. Partial sweeps form
8+
ring segments closed by planar end faces. Inner and outer walls use smooth radial
9+
normals; the top, bottom and segment ends have separate flat normals.
10+
11+
```typescript
12+
import {RingGeometry} from '@math.gl/geometry';
13+
14+
const segment = new RingGeometry({
15+
innerRadius: 0.45, outerRadius: 0.9, height: 0.35,
16+
startAngle: Math.PI / 4, sweepAngle: Math.PI * 1.5
17+
});
18+
```
19+
20+
| Property | Default | Meaning |
21+
| --- | --- | --- |
22+
| `innerRadius` | `0.5` | Positive inner radius, smaller than the outer radius. |
23+
| `outerRadius` | `1` | Positive outer radius. |
24+
| `height` | `0.1` | Positive total height centered on Y=0. |
25+
| `segments` | `48` | Integer subdivisions across the sweep, at least three. |
26+
| `startAngle` | `0` | Finite start angle in radians. |
27+
| `sweepAngle` | `2π` | Positive sweep in radians, at most 2π. |
28+
29+
Angles begin on +X and rotate toward −Z. Full annuli have no radial end faces.
30+
Generated meshes use indexed `triangle-list` topology and Float32 `POSITION`,
31+
`NORMAL` and `TEXCOORD_0` attributes. `id` and additional attribute overrides are
32+
supported. These are CPU buffers for any renderer; no GPU resources are created.

‎docs/table-of-contents.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@
112112
"modules/geometry/api-reference/plane-geometry",
113113
"modules/geometry/api-reference/sphere-geometry",
114114
"modules/geometry/api-reference/ico-sphere-geometry",
115+
"modules/geometry/api-reference/prism-geometry",
116+
"modules/geometry/api-reference/ring-geometry",
115117
"modules/geometry/api-reference/parametric"
116118
]
117119
},

‎docs/whats-new.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
## v5.0
1010

11+
- `@math.gl/geometry` adds flat-sided `PrismGeometry` and solid `RingGeometry`, including
12+
hexagonal prisms and capped annular segments. The optional parametric entry adds
13+
rotated, capped segments to `TorusGeometry` while preserving its full-torus defaults.
14+
15+
1116
Status: prerelease development. TypeScript consumers require TypeScript 6.0 or later; JavaScript still targets ES2020.
1217

1318
**`@math.gl/dggs`** (NEW MODULE)

‎examples/geometry-viewer/app.jsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,16 @@ export const presets = {
2222
},
2323
PlaneGeometry: {sizeX: 2, sizeZ: 2, nx: 6, nz: 6},
2424
SphereGeometry: {radius: 0.75, nlat: 16, nlong: 24},
25-
IcoSphereGeometry: {radius: 0.75, iterations: 2}
25+
IcoSphereGeometry: {radius: 0.75, iterations: 2},
26+
PrismGeometry: {radius: 0.6, height: 1.5, sides: 6, startAngle: Math.PI / 6},
27+
RingGeometry: {
28+
innerRadius: 0.45,
29+
outerRadius: 0.9,
30+
height: 0.35,
31+
segments: 48,
32+
startAngle: Math.PI / 4,
33+
sweepAngle: Math.PI * 1.5
34+
}
2635
};
2736

2837
/** Supply a Geometry instance directly, or a constructor name and constructor props. */

‎examples/parametric-geometry/app.jsx‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export default function Example() {
4242
const [shape, setShape] = useState('torus'),
4343
[segments, setSegments] = useState(48),
4444
[amount, setAmount] = useState(0.4),
45+
[torusArc, setTorusArc] = useState(Math.PI * 2),
46+
[torusStartAngle, setTorusStartAngle] = useState(0),
47+
[torusCaps, setTorusCaps] = useState(true),
4548
[wireframe, setWireframe] = useState(false),
4649
[starter, setStarter] = useState('Wave'),
4750
[source, setSource] = useState(functions.Wave),
@@ -92,7 +95,10 @@ export default function Example() {
9295
return new TorusGeometry({
9396
majorSegments: segments,
9497
minorSegments: Math.max(8, Math.floor(segments / 2)),
95-
minorRadius: amount
98+
minorRadius: amount,
99+
arc: torusArc,
100+
startAngle: torusStartAngle,
101+
cap: torusCaps
96102
});
97103
if (shape === 'lathe')
98104
return new LatheGeometry({
@@ -116,7 +122,11 @@ export default function Example() {
116122
return [x, amount * Math.sin(x * 3) * Math.cos(z * 3), -z];
117123
}
118124
});
119-
}, [shape, segments, amount]);
125+
}, [shape, segments, amount, torusArc, torusStartAngle, torusCaps]);
126+
const surfaceFunction =
127+
shape === 'torus'
128+
? presetFunctions.torus.replace('u * Math.PI * 2', `${torusStartAngle} + u * ${torusArc}`)
129+
: presetFunctions[shape];
120130
const displayed = shape === 'function' && customGeometry ? customGeometry : geometry;
121131
return (
122132
<div className="processing-example">
@@ -145,26 +155,66 @@ export default function Example() {
145155
<option value="function">Function</option>
146156
</select>
147157
</label>
158+
{shape === 'torus' && (
159+
<>
160+
<label>
161+
Sweep · {Math.round((torusArc * 180) / Math.PI)}°
162+
<input
163+
aria-label="Torus sweep"
164+
type="range"
165+
min="30"
166+
max="360"
167+
step="15"
168+
value={Math.round((torusArc * 180) / Math.PI)}
169+
onChange={event => setTorusArc((Number(event.target.value) * Math.PI) / 180)}
170+
/>
171+
</label>
172+
<label>
173+
Start · {Math.round((torusStartAngle * 180) / Math.PI)}°
174+
<input
175+
aria-label="Torus start angle"
176+
type="range"
177+
min="0"
178+
max="360"
179+
step="15"
180+
value={Math.round((torusStartAngle * 180) / Math.PI)}
181+
onChange={event => setTorusStartAngle((Number(event.target.value) * Math.PI) / 180)}
182+
/>
183+
</label>
184+
<label>
185+
<input
186+
type="checkbox"
187+
checked={torusCaps}
188+
disabled={torusArc === Math.PI * 2}
189+
onChange={event => setTorusCaps(event.target.checked)}
190+
/>
191+
Close segment ends
192+
</label>
193+
</>
194+
)}
148195
{shape !== 'function' && (
149196
<div className="function-editor">
150197
<p>Surface function · (u, v, a) → [x, y, z]</p>
151198
<pre>
152-
<code>{presetFunctions[shape]}</code>
199+
<code>{surfaceFunction}</code>
153200
</pre>
154201
<button
155202
type="button"
156203
onClick={() => {
157204
setCustomGeometry(displayed);
158205
setStarter('');
159-
setSource(presetFunctions[shape]);
160-
setAppliedSource(presetFunctions[shape]);
206+
setSource(surfaceFunction);
207+
setAppliedSource(surfaceFunction);
161208
setRevision(value => value + 1);
162209
setShape('function');
163210
}}
164211
>
165212
Edit this function
166213
</button>
167-
<p>The primitive also defines its normals, seams, and sampling resolution.</p>
214+
<p>
215+
The primitive also defines its normals, seams, and sampling resolution. Torus end caps
216+
are separate faces from this curved surface.
217+
</p>
168218
</div>
169219
)}
170220
{shape === 'function' && (

‎modules/geometry/scripts/check-parametric-package.mjs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,28 @@ try {
2121
await copyFile(join(source, 'package.json'), join(scope, name, 'package.json'));
2222
}
2323
await writeFile(join(temporary, 'package.json'), '{"type":"module"}');
24-
const checks = `assert.equal(new api.TorusGeometry().topology,'triangle-list');
24+
const checks = `assert.equal(new primitives.PrismGeometry().topology,'triangle-list');
25+
assert.equal(new primitives.RingGeometry({sweepAngle:Math.PI}).attributes.POSITION.size,3);
26+
assert.equal(new api.TorusGeometry().topology,'triangle-list');
2527
assert.equal(new api.LatheGeometry({points:[[1,0],[1,1]]}).attributes.POSITION.size,3);
2628
assert.equal(new api.ParametricGeometry({uSegments:1,vSegments:1,sample:(u,v)=>[u,v,0]}).vertexCount,6);`;
2729
await writeFile(
2830
join(temporary, 'consumer.mjs'),
29-
`import assert from 'node:assert/strict';import * as api from '@math.gl/geometry/parametric';${checks}`
31+
`import assert from 'node:assert/strict';import * as api from '@math.gl/geometry/parametric';import * as primitives from '@math.gl/geometry';${checks}`
3032
);
3133
await writeFile(
3234
join(temporary, 'consumer.cjs'),
33-
`const assert=require('node:assert/strict'),api=require('@math.gl/geometry/parametric');${checks}`
35+
`const assert=require('node:assert/strict'),api=require('@math.gl/geometry/parametric'),primitives=require('@math.gl/geometry');${checks}`
3436
);
3537
for (const file of ['consumer.mjs', 'consumer.cjs'])
3638
execFileSync(process.execPath, [join(temporary, file)], {stdio: 'inherit'});
3739
await writeFile(
3840
join(temporary, 'consumer.ts'),
3941
`import {ParametricGeometry,TorusGeometry,LatheGeometry,type SurfaceSampler} from '@math.gl/geometry/parametric';
42+
import {PrismGeometry,RingGeometry,type PrismGeometryProps,type RingGeometryProps} from '@math.gl/geometry';
43+
const prism:PrismGeometryProps={sides:6,startAngle:1};new PrismGeometry(prism);
44+
const ring:RingGeometryProps={innerRadius:.5,outerRadius:1,sweepAngle:1};new RingGeometry(ring);
45+
new TorusGeometry({arc:1,startAngle:1,cap:true});
4046
const sample:SurfaceSampler=(u,v)=>new Float64Array([u,v,0]);new ParametricGeometry({sample});new TorusGeometry();new LatheGeometry({points:[[1,0],[1,1]]});`
4147
);
4248
execFileSync(
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// math.gl
2+
// SPDX-License-Identifier: MIT
3+
// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
4+
5+
import type {GeometryProps} from '../lib/geometry';
6+
import {assertSegments, makeIndices, type PrimitiveGeometryProps} from './geometry-helpers';
7+
export const FULL_CIRCLE = Math.PI * 2;
8+
export type Vector = [number, number, number];
9+
export type Vertex = {position: Vector; normal: Vector; textureCoordinates: [number, number]};
10+
export function radial(angle: number, direction = 1): Vector {
11+
return [Math.cos(angle) * direction, 0, -Math.sin(angle) * direction];
12+
}
13+
export function vertex(
14+
radius: number,
15+
height: number,
16+
angle: number,
17+
normal: Vector,
18+
textureCoordinates?: [number, number]
19+
): Vertex {
20+
return {
21+
position: [radius * Math.cos(angle), height, -radius * Math.sin(angle)],
22+
normal,
23+
textureCoordinates: textureCoordinates ?? [(Math.cos(angle) + 1) / 2, (Math.sin(angle) + 1) / 2]
24+
};
25+
}
26+
export function validateArc(segments: number, startAngle: number, sweepAngle: number): void {
27+
assertSegments(segments, 'segments', 3);
28+
if (!Number.isFinite(startAngle)) throw new Error('startAngle must be finite');
29+
if (!Number.isFinite(sweepAngle) || sweepAngle <= 0 || sweepAngle > FULL_CIRCLE)
30+
throw new Error('sweepAngle must be positive and at most 2π');
31+
}
32+
export class PrimitiveMeshBuilder {
33+
private positions: number[] = [];
34+
private normals: number[] = [];
35+
private textureCoordinates: number[] = [];
36+
private indices: number[] = [];
37+
private addVertex(current: Vertex): number {
38+
const index = this.positions.length / 3;
39+
this.positions.push(...current.position);
40+
this.normals.push(...current.normal);
41+
this.textureCoordinates.push(...current.textureCoordinates);
42+
return index;
43+
}
44+
private addTriangle(first: Vertex, second: Vertex, third: Vertex, indices: number[]): void {
45+
const firstEdge = second.position.map((value, index) => value - first.position[index]);
46+
const secondEdge = third.position.map((value, index) => value - first.position[index]);
47+
const cross = [
48+
firstEdge[1] * secondEdge[2] - firstEdge[2] * secondEdge[1],
49+
firstEdge[2] * secondEdge[0] - firstEdge[0] * secondEdge[2],
50+
firstEdge[0] * secondEdge[1] - firstEdge[1] * secondEdge[0]
51+
];
52+
const orientation = cross.reduce(
53+
(sum, value, index) =>
54+
sum + value * (first.normal[index] + second.normal[index] + third.normal[index]),
55+
0
56+
);
57+
this.indices.push(...(orientation < 0 ? [indices[0], indices[2], indices[1]] : indices));
58+
}
59+
triangle(first: Vertex, second: Vertex, third: Vertex): void {
60+
this.addTriangle(first, second, third, [
61+
this.addVertex(first),
62+
this.addVertex(second),
63+
this.addVertex(third)
64+
]);
65+
}
66+
quad(first: Vertex, second: Vertex, third: Vertex, fourth: Vertex): void {
67+
const indices = [first, second, third, fourth].map(current => this.addVertex(current));
68+
this.addTriangle(first, second, third, indices.slice(0, 3));
69+
this.addTriangle(first, third, fourth, [indices[0], indices[2], indices[3]]);
70+
}
71+
getProps(props: PrimitiveGeometryProps): GeometryProps {
72+
return {
73+
id: props.id,
74+
topology: 'triangle-list',
75+
indices: makeIndices(this.positions.length / 3, this.indices),
76+
attributes: {
77+
POSITION: {size: 3, value: new Float32Array(this.positions)},
78+
NORMAL: {size: 3, value: new Float32Array(this.normals)},
79+
TEXCOORD_0: {size: 2, value: new Float32Array(this.textureCoordinates)},
80+
...props.attributes
81+
}
82+
};
83+
}
84+
}

0 commit comments

Comments
 (0)