Skip to content

Commit fcaef2e

Browse files
committed
feat: add 3D renderers and release v0.2.0
1 parent 14421d2 commit fcaef2e

File tree

15 files changed

+2806
-269
lines changed

15 files changed

+2806
-269
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ dist-lib/
88
research/
99
scripts/
1010
.playwright-mcp/
11+
context/
12+
.specstory/

README.md

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
A specialized scatterplot engine for [HyperView](https://github.com/HackerRoomAI/HyperView).
2020

21-
- Geometries: **Poincaré (hyperbolic)** + **Euclidean** today; **Spherical (S²)** is a good future contribution.
22-
- Correctness: a slow CPU **Reference** defines semantics; the fast GPU **Candidate** must match.
21+
- Geometries: **Poincaré (hyperbolic)** + **Euclidean** in 2D, plus WebGL candidates for **Euclidean 3D** and **Sphere**.
22+
- Correctness: a slow CPU **Reference** defines 2D semantics; the fast GPU **Candidate** must match.
2323
- Implementation: **pure WebGL2** (no `regl`, no `three.js`, no runtime deps).
2424

2525
---
@@ -40,52 +40,26 @@ For the full invariants + how the harness selects candidate code paths, see [AGE
4040
## Usage (copy/paste agent prompt)
4141

4242
```text
43-
You are a coding agent working in my repository.
44-
45-
Use these imports:
46-
47-
import {
48-
EuclideanWebGLCandidate,
49-
HyperbolicWebGLCandidate,
50-
createDataset,
51-
createInteractionController,
52-
type SelectionResult,
53-
} from 'hyper-scatter';
54-
55-
Goal:
56-
- Integrate `hyper-scatter` to render my embedding scatterplot.
57-
58-
Requirements:
59-
1) Install:
60-
- npm: `npm install hyper-scatter`
61-
62-
2) Implement a small integration wrapper:
63-
- Create `mountHyperScatter(canvas, params)` (or an idiomatic React hook).
64-
- Pick renderer:
65-
- if params.geometry === 'poincare' use `new HyperbolicWebGLCandidate()`
66-
- else use `new EuclideanWebGLCandidate()`
67-
- Ensure the canvas has a real CSS size (non-zero width/height).
68-
- Init using CSS pixels:
69-
- `const rect = canvas.getBoundingClientRect()`
70-
- `renderer.init(canvas, { width: Math.max(1, Math.floor(rect.width)), height: Math.max(1, Math.floor(rect.height)), devicePixelRatio: window.devicePixelRatio })`
71-
- Dataset:
72-
- `renderer.setDataset(createDataset(params.geometry, params.positions, params.labels))`
73-
- First frame:
74-
- `renderer.render()`
75-
76-
3) Wire interactions:
77-
- Use `createInteractionController(canvas, renderer, { onHover, onLassoComplete })`.
78-
- On lasso completion, keep the returned `SelectionResult` and (optionally) call:
79-
- `await renderer.countSelection(result, { yieldEveryMs: 0 })` if you need an exact count without UI yielding.
80-
81-
4) Cleanup:
82-
- On unmount/destroy: `controller.destroy(); renderer.destroy();`
83-
84-
Deliverables:
85-
- The concrete code changes + file paths.
86-
- A minimal example showing how to pass `Float32Array positions` (flat [x,y,x,y,...]) and optional `Uint16Array labels`.
43+
You are a coding agent; integrate `hyper-scatter` in my repo: install `npm install hyper-scatter`; create `mountHyperScatter(canvas, params)` (or React hook) using `EuclideanWebGLCandidate`, `HyperbolicWebGLCandidate`, `Euclidean3DWebGLCandidate`, `Spherical3DWebGLCandidate`, `createDataset`, `createDataset3D`, and `createInteractionController`; choose renderer by `params.geometry` (`euclidean`, `poincare`, `euclidean3d`, `sphere`); ensure non-zero CSS size, then `const rect = canvas.getBoundingClientRect()` and `renderer.init(canvas, { width: Math.max(1, Math.floor(rect.width)), height: Math.max(1, Math.floor(rect.height)), devicePixelRatio: window.devicePixelRatio })`; set dataset with `createDataset` for 2D or `createDataset3D` for 3D, then `renderer.render()`; wire `createInteractionController(canvas, renderer, { onHover, onLassoComplete })` only for 2D and optionally call `await renderer.countSelection(result, { yieldEveryMs: 0 })` for exact lasso counts; cleanup with `controller?.destroy(); renderer.destroy();`; return concrete code changes + file paths + a minimal example passing `Float32Array` positions (`[x,y,...]` or `[x,y,z,...]`) and optional `Uint16Array` labels.
8744
```
8845

46+
## API Highlights
47+
48+
Runtime updates (no renderer re-creation): `setPalette`, `setCategoryVisibility`, `setCategoryAlpha`, `setInteractionStyle`.
49+
50+
| Dimension | Geometry token | Geometry | Candidate class | Dataset helper |
51+
|---|---|---|---|---|
52+
| 2D | `euclidean` | Euclidean | `EuclideanWebGLCandidate` | `createDataset` |
53+
| 2D | `poincare` | Poincare (hyperbolic disk) | `HyperbolicWebGLCandidate` | `createDataset` |
54+
| 3D | `euclidean3d` | Euclidean 3D | `Euclidean3DWebGLCandidate` | `createDataset3D` |
55+
| 3D | `sphere` | Hypersphere (unit sphere) | `Spherical3DWebGLCandidate` | `createDataset3D` |
56+
57+
Notes:
58+
- Hidden categories are excluded from `render`, `hitTest`, and `lassoSelect`.
59+
- `createInteractionController()` targets the 2D `Renderer` interface.
60+
- 2D `SelectionResult` supports `kind: 'indices' | 'geometry'`; `SelectionResult3D` is index-based.
61+
- 3D helper exports include `packPositionsXYZ`.
62+
8963
## Benchmarks
9064

9165
Main claim, measured via the browser harness (headed):
@@ -105,6 +79,11 @@ npm run bench -- --points=20000000
10579

10680
Default sweep (smaller point counts): `npm run bench`
10781

82+
Additional benchmark options:
83+
84+
- `npm run bench -- --geometries=euclidean,poincare,euclidean3d,sphere` runs the WebGL candidate benchmark across 2D and 3D geometries.
85+
- `npm run bench -- --renderer=reference` and `npm run bench:accuracy` remain 2D-only.
86+
10887
Note: for performance numbers, run headed (default). Headless runs can skew GPU timing.
10988

11089
## How we built it
@@ -145,7 +124,8 @@ The harness tries to reduce these paths (example: lasso timing is end-to-end and
145124

146125
- [x] Euclidean Geometry
147126
- [x] Poincaré Disk (Hyperbolic) Geometry
148-
- [ ] **Spherical Geometry (S²)**: The architecture supports it (`GeometryMode` enum), but the Reference math is missing. Contributions welcome.
127+
- [x] 3D WebGL candidates (`euclidean3d`, `sphere`)
128+
- [ ] 3D reference renderer + accuracy harness
149129

150130
## License
151131

benchmark.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@
114114
</div>
115115
<div class="control-group">
116116
<label>Geometries</label>
117-
<select id="geometriesSelect" multiple size="2">
117+
<select id="geometriesSelect" multiple size="4">
118118
<option value="euclidean" selected>Euclidean</option>
119119
<option value="poincare" selected>Poincaré</option>
120+
<option value="euclidean3d">Euclidean 3D</option>
121+
<option value="sphere">Sphere</option>
120122
</select>
121123
</div>
122124
<div class="control-group">
@@ -223,6 +225,11 @@
223225
return;
224226
}
225227

228+
if (config.renderer === 'reference' && config.geometries.some((g) => g === 'euclidean3d' || g === 'sphere')) {
229+
alert('Reference renderer supports only euclidean and poincare. Use WebGL for 3D geometries.');
230+
return;
231+
}
232+
226233
runBenchmarkBtn.disabled = runAccuracyBtn.disabled = true;
227234
progressContainer.classList.add('active');
228235

index.html

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='45' fill='none' stroke='%23888' stroke-width='3'/><circle cx='35' cy='40' r='5' fill='%23666'/><circle cx='65' cy='35' r='4' fill='%23666'/><circle cx='50' cy='60' r='6' fill='%23666'/></svg>">
1414
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
1515
<style>
16-
:root{color-scheme:light dark;--pico-font-size:14px;--pico-line-height:1.4;--viz-bg:#ffffff;--viz-disk:#f2f4f7;--viz-border:#8a9199;--viz-grid:#c9cfd880}
17-
@media(prefers-color-scheme:dark){:root{--viz-bg:#13171f;--viz-disk:#1b2230;--viz-border:#6b7280;--viz-grid:#2b334266}}
16+
:root{color-scheme:light dark;--pico-font-size:14px;--pico-line-height:1.4;--viz-bg:#13171f;--viz-disk:#1b2230;--viz-border:#94a3b8;--viz-grid:#2b334266}
1817
*{margin:0;padding:0;box-sizing:border-box}
1918
html,body{height:100%}
2019
body{display:flex;flex-direction:column}
@@ -90,10 +89,14 @@
9089
<fieldset>
9190
<legend>Geometry</legend>
9291
<div role="group">
93-
<input id="geomEuclidean" type="radio" name="geometry" value="euclidean">
94-
<label for="geomEuclidean">Euclidean</label>
95-
<input id="geomPoincare" type="radio" name="geometry" value="poincare" checked>
92+
<input id="geomEuclidean" type="radio" name="geometry" value="euclidean" checked>
93+
<label for="geomEuclidean">Euc 2D</label>
94+
<input id="geomPoincare" type="radio" name="geometry" value="poincare">
9695
<label for="geomPoincare">Poincaré</label>
96+
<input id="geomEuclidean3D" type="radio" name="geometry" value="euclidean3d">
97+
<label for="geomEuclidean3D">Euc 3D</label>
98+
<input id="geomSphere" type="radio" name="geometry" value="sphere">
99+
<label for="geomSphere">Sphere</label>
97100
</div>
98101
</fieldset>
99102

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hyper-scatter",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "High-performance WebGL scatter plot renderer for Euclidean, Hyperbolic, and non-Euclidean embeddings.",
55
"keywords": [
66
"webgl",

src/benchmarks/browser.ts

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,24 @@
4949
* ============================================================================
5050
*/
5151

52-
import { GeometryMode, Renderer } from '../core/types.js';
52+
import { GeometryMode, Renderer, SelectionResult } from '../core/types.js';
53+
import {
54+
Dataset3D,
55+
GeometryMode3D,
56+
Renderer3D,
57+
SelectionResult3D,
58+
} from '../core/types3d.js';
5359
import { generateDataset } from '../core/dataset.js';
5460
import { EuclideanReference } from '../impl_reference/euclidean_reference.js';
5561
import { HyperbolicReference } from '../impl_reference/hyperbolic_reference.js';
5662
import {
5763
EuclideanWebGLCandidate,
5864
HyperbolicWebGLCandidate,
5965
} from '../impl_candidate/webgl_candidate.js';
66+
import {
67+
Euclidean3DWebGLCandidate,
68+
Spherical3DWebGLCandidate,
69+
} from '../impl_candidate/webgl_candidate_3d.js';
6070
import {
6171
TimingStats,
6272
calculateStats,
@@ -75,7 +85,7 @@ import {
7585

7686
export interface BenchmarkConfig {
7787
pointCounts: number[];
78-
geometries: GeometryMode[];
88+
geometries: BenchmarkGeometry[];
7989
warmupFrames: number;
8090
measuredFrames: number;
8191
hitTestSamples: number;
@@ -97,7 +107,7 @@ export interface BenchmarkConfig {
97107
}
98108

99109
export interface BenchmarkResult {
100-
geometry: GeometryMode;
110+
geometry: BenchmarkGeometry;
101111
points: number;
102112
datasetGenMs: number;
103113
renderMs: TimingStats;
@@ -133,6 +143,7 @@ export interface BenchmarkReport {
133143
}
134144

135145
export type ProgressCallback = (message: string, progress: number) => void;
146+
export type BenchmarkGeometry = GeometryMode | GeometryMode3D;
136147

137148
// ============================================================================
138149
// Default Configuration
@@ -158,6 +169,52 @@ export const STRESS_CONFIG: BenchmarkConfig = {
158169
renderer: 'webgl',
159170
};
160171

172+
function is2DGeometry(geometry: BenchmarkGeometry): geometry is GeometryMode {
173+
return geometry === 'euclidean' || geometry === 'poincare';
174+
}
175+
176+
function is3DGeometry(geometry: BenchmarkGeometry): geometry is GeometryMode3D {
177+
return geometry === 'euclidean3d' || geometry === 'sphere';
178+
}
179+
180+
function createRng(seed: number): () => number {
181+
let state = seed >>> 0;
182+
return () => {
183+
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
184+
return state / 0x100000000;
185+
};
186+
}
187+
188+
function generateDataset3DForBenchmark(geometry: GeometryMode3D, n: number): Dataset3D {
189+
const positions = new Float32Array(n * 3);
190+
const labels = new Uint16Array(n);
191+
const rand = createRng(42 + n + (geometry === 'sphere' ? 1337 : 0));
192+
193+
for (let i = 0; i < n; i++) {
194+
labels[i] = i % 10;
195+
196+
if (geometry === 'sphere') {
197+
const u = rand() * 2 - 1;
198+
const theta = rand() * Math.PI * 2;
199+
const s = Math.sqrt(Math.max(0, 1 - u * u));
200+
positions[i * 3] = s * Math.cos(theta);
201+
positions[i * 3 + 1] = u;
202+
positions[i * 3 + 2] = s * Math.sin(theta);
203+
} else {
204+
positions[i * 3] = (rand() * 2 - 1) * 1.5;
205+
positions[i * 3 + 1] = (rand() * 2 - 1) * 1.5;
206+
positions[i * 3 + 2] = (rand() * 2 - 1) * 1.5;
207+
}
208+
}
209+
210+
return {
211+
n,
212+
positions,
213+
labels,
214+
geometry,
215+
};
216+
}
217+
161218
function getRendererMode(config: BenchmarkConfig): 'webgl' | 'reference' {
162219
return config.renderer ?? 'webgl';
163220
}
@@ -227,7 +284,7 @@ function setBenchmarkCanvasVisibility(
227284
*/
228285
async function runSingleBenchmark(
229286
canvas: HTMLCanvasElement,
230-
geometry: GeometryMode,
287+
geometry: BenchmarkGeometry,
231288
pointCount: number,
232289
config: BenchmarkConfig,
233290
onProgress?: ProgressCallback
@@ -246,27 +303,50 @@ async function runSingleBenchmark(
246303

247304
onProgress?.(`Generating ${pointCount.toLocaleString()} ${geometry} points...`, 0);
248305

249-
// Generate dataset
250-
const datasetStart = performance.now();
251-
const dataset = generateDataset({
252-
seed: 42,
253-
n: pointCount,
254-
labelCount: 10,
255-
geometry,
256-
});
257-
const datasetGenMs = performance.now() - datasetStart;
306+
let renderer: Renderer | Renderer3D;
307+
let datasetGenMs: number;
308+
309+
if (is2DGeometry(geometry)) {
310+
const datasetStart = performance.now();
311+
const dataset = generateDataset({
312+
seed: 42,
313+
n: pointCount,
314+
labelCount: 10,
315+
geometry,
316+
});
317+
datasetGenMs = performance.now() - datasetStart;
318+
319+
renderer = rendererMode === 'reference'
320+
? (geometry === 'euclidean' ? new EuclideanReference() : new HyperbolicReference())
321+
: (geometry === 'euclidean' ? new EuclideanWebGLCandidate() : new HyperbolicWebGLCandidate());
322+
323+
renderer.init(renderCanvas, {
324+
width,
325+
height,
326+
devicePixelRatio: window.devicePixelRatio,
327+
});
328+
renderer.setDataset(dataset);
329+
} else {
330+
if (rendererMode === 'reference') {
331+
throw new Error(`Geometry ${geometry} requires renderer='webgl' (reference mode is 2D-only).`);
332+
}
333+
334+
const datasetStart = performance.now();
335+
const dataset = generateDataset3DForBenchmark(geometry, pointCount);
336+
datasetGenMs = performance.now() - datasetStart;
258337

259-
// Create renderer
260-
const renderer: Renderer = rendererMode === 'reference'
261-
? (geometry === 'euclidean' ? new EuclideanReference() : new HyperbolicReference())
262-
: (geometry === 'euclidean' ? new EuclideanWebGLCandidate() : new HyperbolicWebGLCandidate());
338+
renderer = geometry === 'sphere'
339+
? new Spherical3DWebGLCandidate()
340+
: new Euclidean3DWebGLCandidate();
263341

264-
renderer.init(renderCanvas, {
265-
width,
266-
height,
267-
devicePixelRatio: window.devicePixelRatio,
268-
});
269-
renderer.setDataset(dataset);
342+
renderer.init(renderCanvas, {
343+
width,
344+
height,
345+
devicePixelRatio: window.devicePixelRatio,
346+
pointRadius: 3,
347+
});
348+
renderer.setDataset(dataset);
349+
}
270350

271351
onProgress?.(`Warming up render...`, 0.1);
272352

@@ -436,7 +516,9 @@ async function runSingleBenchmark(
436516
const lassoPolygon = generateTestPolygon(width / 2, height / 2, lassoRadius);
437517
const lassoStart = performance.now();
438518
const lassoResult = renderer.lassoSelect(lassoPolygon);
439-
const lassoSelectedCount = await renderer.countSelection(lassoResult, { yieldEveryMs: 0 });
519+
const lassoSelectedCount = is3DGeometry(geometry)
520+
? await (renderer as Renderer3D).countSelection(lassoResult as SelectionResult3D, { yieldEveryMs: 0 })
521+
: await (renderer as Renderer).countSelection(lassoResult as SelectionResult, { yieldEveryMs: 0 });
440522
const lassoMs = performance.now() - lassoStart;
441523

442524
// Memory usage (Chrome only)
@@ -495,6 +577,15 @@ export async function runBenchmarks(
495577
const candidateCanvas = getOrCreateCandidateCanvas(canvas);
496578
setBenchmarkCanvasVisibility(canvas, candidateCanvas, rendererMode);
497579

580+
if (rendererMode === 'reference') {
581+
const unsupported = config.geometries.filter(is3DGeometry);
582+
if (unsupported.length > 0) {
583+
throw new Error(
584+
`Reference mode only supports 2D geometries (euclidean, poincare). Unsupported: ${unsupported.join(', ')}`
585+
);
586+
}
587+
}
588+
498589
for (const geometry of config.geometries) {
499590
for (const pointCount of config.pointCounts) {
500591
onProgress?.(

0 commit comments

Comments
 (0)