Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/quiet-vectors-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@osmix/cli": patch
---

Add an OpenTUI Three.js WebGPU vector backend, with terminal-pixel output through Kitty Graphics or
Sixel when supported, while retaining quadrant and raster compatibility fallbacks. The
`OSMIX_CLI_RENDERER` environment variable selects or requires a backend.
23 changes: 19 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @osmix/cli

`@osmix/cli` provides the `osmix` command for exploring a local OSM PBF file in an interactive terminal map. It parses and indexes the file with the `osmix` facade, renders styled XYZ raster tiles, and displays them through an OpenTUI framebuffer.
`@osmix/cli` provides the `osmix` command for exploring a local OSM PBF file in an interactive terminal map. It parses and indexes the file with the `osmix` facade, and renders a Shortbread-styled map with an OpenTUI Three.js vector backend when WebGPU is available. The existing raster renderer remains available as a compatibility fallback.

## Installation

Expand All @@ -25,7 +25,7 @@ against the release's `SHA256SUMS` file. Minimal Alpine installations may also n

### Bun package

The native OpenTUI renderer requires [Bun](https://bun.sh/).
The native OpenTUI renderer requires [Bun](https://bun.sh/). The vector backend also requires a Bun runtime with WebGPU support. The default `auto` mode initializes Three.js once and falls back to raster if WebGPU initialization fails.

```sh
bun add --global @osmix/cli
Expand All @@ -37,9 +37,21 @@ bun add --global @osmix/cli
osmix monaco.pbf
```

Set `OSMIX_CLI_RENDERER=raster` to force the compatibility renderer, or
`OSMIX_CLI_RENDERER=vector` to require the WebGPU vector backend and report initialization errors
instead of falling back.

On terminals with Kitty Graphics or Sixel support, the vector backend presents the WebGPU map at
the terminal's reported pixel resolution. This avoids the Unicode 2-by-2 quadrant conversion used
by `@opentui/three` while OpenTUI text labels and controls remain regular terminal cells above the
map. Other terminals use the quadrant output as a compatibility fallback. The status bar reports
`vector/kitty`, `vector/sixel`, or `vector/quadrants`. OpenTUI's
`OPENTUI_IMAGE_PROTOCOL=kitty|sixel|blocks` override can force a presentation protocol, and
`OPENTUI_GRAPHICS=false` disables pixel graphics.

The viewer opens immediately and reports parsing progress in its status bar. PBF streaming, semantic indexing, label queries, and missing map tiles stay in Web Workers so the spinner and controls remain responsive throughout loading. One logical core remains available for OpenTUI and input. The shared Osmix worker runtime supplies availability scheduling, timeouts, retry-once recovery, and diagnostics; the CLI adds a control lane for labels and compute lanes for tiles. Runtimes without shared buffers use one worker without copying the dataset onto the main thread.

The main thread retains only dataset metadata and prepared pixels. Labels arrive asynchronously for the latest camera revision, and stale results are discarded after a pan, zoom, or resize. Pending tiles use a sparse diagonal shimmer drawn during OpenTUI post-processing, while cached portions remain unchanged. Tile work is dispatched independently of successful terminal frames, so output backpressure cannot stall the queue. Shared-buffer workers cancel stale tiles through the common atomic generation gate; the single-worker fallback yields between rendering chunks so an out-of-band cancellation notification can run without moving work onto the main thread. A failed worker is restarted and rehydrated once; the viewer reports a repeated failure instead of falling back to blocking local parsing or rendering.
The main thread retains only dataset metadata and prepared geometry packets. Labels arrive asynchronously for the latest camera revision, and stale results are discarded after a pan, zoom, or resize. Vector tile packets contain transferable CPU geometry; Three.js objects and WebGPU resources are created only on the main thread and are disposed when tiles leave the scene. Pending tiles use a sparse diagonal shimmer drawn during OpenTUI post-processing, while cached portions remain unchanged. Tile work is dispatched independently of successful terminal frames, so output backpressure cannot stall the queue. Shared-buffer workers cancel stale tiles through the common atomic generation gate; the single-worker fallback yields between rendering chunks so an out-of-band cancellation notification can run without moving work onto the main thread. A failed worker is restarted and rehydrated once; the viewer reports a repeated failure instead of falling back to blocking local parsing or rendering.

The built-in dark basemap classifies OSM features with the Shortbread schema. Water, land use, buildings, boundaries, transportation, and selected points use distinct, high-contrast colors and a stable layer order. Road colors and widths follow their highway class, with tunnels below surface streets and bridges above them. Overview zooms show major roads from zoom 7, secondary roads from zoom 8, tertiary and residential streets from zoom 9, and service streets from zoom 10. These additional overview streets use thin uncased strokes until their normal detail zoom. Buildings appear from zoom 13, while paths and point symbols appear from zoom 14.

Expand All @@ -60,7 +72,10 @@ Named places, roads, water, parks, sites, and selected points of interest appear
| `0` | Fit the dataset |
| `q`, Escape, or Ctrl+C | Quit |

The terminal can be resized while the viewer is open. Horizontal panning wraps around the antimeridian; vertical panning is limited to the Web Mercator world bounds.
The terminal can be resized while the viewer is open. The vector texture and image placement are
recomputed from the new cell and pixel dimensions so the map keeps its scale and aspect ratio.
Horizontal panning wraps around the antimeridian; vertical panning is limited to the Web Mercator
world bounds.

## Programmatic usage

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opentui/core": "^0.4.3",
"@opentui/core": "^0.5.1",
"@opentui/three": "^0.5.1",
"@osmix/geo": "workspace:*",
"@osmix/shared": "workspace:*",
"@osmix/shortbread": "workspace:*",
"comlink": "^4.4.2",
"earcut": "^3.2.3",
"osmix": "workspace:*"
},
"devDependencies": {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/scripts/build-executables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,36 @@ async function assertNativePackages(): Promise<void> {
missing.push(nativePackage);
}
}

const stagedPackageRoot = resolve(executableDependencyRoot, "node_modules");
for (const packageName of ["@opentui/core", "@opentui/three"]) {
try {
await stat(resolve(stagedPackageRoot, packageName, "package.json"));
} catch {
missing.push(packageName);
}
}
try {
const threePackage = JSON.parse(
await readFile(resolve(stagedPackageRoot, "@opentui/three/package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const dependencies = {
...threePackage.dependencies,
...threePackage.optionalDependencies,
};
for (const dependency of Object.keys(dependencies)) {
try {
await stat(resolve(stagedPackageRoot, dependency, "package.json"));
} catch {
missing.push(dependency);
}
}
} catch {
// The missing @opentui/three package is reported by the check above.
}
if (missing.length === 0) return;

const corePackagePath = resolve(
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/scripts/prepare-executable-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { executableDependencyRoot } from "./executable-targets.ts";

const packageRoot = resolve(import.meta.dirname, "..");
const corePackagePath = resolve(packageRoot, "node_modules/@opentui/core/package.json");
const threePackagePath = resolve(packageRoot, "node_modules/@opentui/three/package.json");
const corePackage = JSON.parse(await readFile(corePackagePath, "utf8")) as { version: string };
const threePackage = JSON.parse(await readFile(threePackagePath, "utf8")) as { version: string };

await rm(executableDependencyRoot, { force: true, recursive: true });
await mkdir(executableDependencyRoot, { recursive: true });
Expand All @@ -17,9 +19,18 @@ await writeFile(

const install = spawnSync(
process.execPath,
["install", "--no-save", "--os=*", "--cpu=*", `@opentui/core@${corePackage.version}`],
[
"install",
"--no-save",
"--os=*",
"--cpu=*",
`@opentui/core@${corePackage.version}`,
`@opentui/three@${threePackage.version}`,
],
{ cwd: executableDependencyRoot, stdio: "inherit" },
);
if (install.status !== 0) throw Error("Unable to prepare OpenTUI native packages.");

process.stdout.write(`Prepared OpenTUI ${corePackage.version} native packages.\n`);
process.stdout.write(
`Prepared OpenTUI core ${corePackage.version} and three ${threePackage.version} native packages.\n`,
);
26 changes: 14 additions & 12 deletions packages/cli/src/map-pixels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export interface PendingTileRegion {
top: number;
}

export type TileProvider = (tile: Tile) => TileImage | null;
export type TileRenderer = (
export type TileProvider<T = TileImage> = (tile: Tile) => T | null;
export type TileRenderer<T = TileImage> = (
tile: Tile,
generation: number,
) => TileImage | null | Promise<TileImage | null>;
) => T | null | Promise<T | null>;

export function formatTileLoadingStatus(pendingCount: number, spinner: string): string {
return `${spinner} Rendering ${pendingCount} ${pendingCount === 1 ? "tile" : "tiles"}…`;
Expand All @@ -34,16 +34,18 @@ interface PendingTile {
tile: Tile;
}

interface OsmTileLoaderOptions {
interface OsmTileLoaderOptions<T> {
maxCachedTiles?: number;
maxConcurrentTiles?: number;
onError?: (error: unknown) => void;
onGenerationChange?: (generation: number) => void;
onPendingChange?: (pendingCount: number) => void;
onTileComplete?: () => void;
renderTile: TileRenderer;
renderTile: TileRenderer<T>;
}

type PreventInference<T> = [T][T extends unknown ? 0 : never];

function modulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
Expand Down Expand Up @@ -161,9 +163,9 @@ export function renderMapPixels(
}

/** Queue cache misses while serving completed tiles from a bounded LRU cache. */
export class OsmTileLoader {
readonly getTile: TileProvider;
private readonly cache = new Map<string, TileImage>();
export class OsmTileLoader<T = TileImage> {
readonly getTile: TileProvider<T>;
private readonly cache = new Map<string, T>();
private readonly inFlight = new Map<string, PendingTile>();
private readonly maxCachedTiles: number;
private readonly maxConcurrentTiles: number;
Expand All @@ -172,12 +174,12 @@ export class OsmTileLoader {
private readonly onPendingChange: (pendingCount: number) => void;
private readonly onTileComplete: () => void;
private readonly pending = new Map<string, PendingTile>();
private readonly renderTile: TileRenderer;
private readonly renderTile: TileRenderer<T>;
private disposed = false;
private failed = false;
private generation = 0;

constructor(options: OsmTileLoaderOptions) {
constructor(options: OsmTileLoaderOptions<PreventInference<T>>) {
this.maxCachedTiles = options.maxCachedTiles ?? MAX_CACHED_TILES;
this.maxConcurrentTiles = Math.max(1, options.maxConcurrentTiles ?? 1);
this.onError = options.onError ?? (() => undefined);
Expand Down Expand Up @@ -248,7 +250,7 @@ export class OsmTileLoader {
this.onPendingChange(0);
}

private completeTile(key: string, pending: PendingTile, rendered: TileImage | null): void {
private completeTile(key: string, pending: PendingTile, rendered: T | null): void {
if (this.disposed || this.failed) return;
this.inFlight.delete(key);
if (!rendered) {
Expand Down Expand Up @@ -278,7 +280,7 @@ export class OsmTileLoader {
this.onError(error);
}

private requestTile(tile: Tile): TileImage | null {
private requestTile(tile: Tile): T | null {
if (this.disposed) return null;
const key = tile.join("/");
const cached = this.cache.get(key);
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/tile-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { TileImage } from "./map-pixels.ts";
import type { SemanticNodeIndexTransferables } from "./semantic-node-index.ts";
import type { SemanticRenderIndexTransferables } from "./semantic-render-index.ts";
import type { MapLabelQueryRequest, MapLabelQueryResult, CliTileWorker } from "./tile-worker.ts";
import type { VectorTilePacket } from "./vector-tile.ts";

const MAX_CLI_WORKERS = 4;
const WORKER_RPC_TIMEOUT_MS = 60_000;
Expand Down Expand Up @@ -51,6 +52,7 @@ export interface StyledTileRenderer {
revision: number;
}>;
renderTile(tile: Tile, generation: number): Promise<TileImage | null>;
renderVectorTile(tile: Tile, generation: number): Promise<VectorTilePacket | null>;
}

interface StyledTileRendererOptions {
Expand Down Expand Up @@ -225,6 +227,23 @@ class CliStyledTileRemote extends OsmixRemote<CliTileWorker> {
return data ? { data } : null;
}

async renderVectorTile(tile: Tile, generation: number): Promise<VectorTilePacket | null> {
this.assertReady();
if (this.generationGate.isCancelled(generation)) return null;
const cancellation = this.generationGate.transferables();
const data = await this.runWithWorker(
(worker) => {
if (this.generationGate.isCancelled(generation)) return null;
if (!this.generationGate.hasSharedState) {
return worker.getCliVectorTileCooperatively(this.datasetInfo!.id, tile, generation);
}
return worker.getCliVectorTile(this.datasetInfo!.id, tile, generation, cancellation);
},
{ lane: "compute", retry: "once", timeoutMs: WORKER_RPC_TIMEOUT_MS },
);
return data;
}

protected override async rehydrateWorker(
worker: Remote<CliTileWorker>,
index: number,
Expand Down Expand Up @@ -327,6 +346,10 @@ class WorkerBackedStyledTileRenderer implements StyledTileRenderer {
renderTile(tile: Tile, generation: number): Promise<TileImage | null> {
return this.remote.renderTile(tile, generation);
}

renderVectorTile(tile: Tile, generation: number): Promise<VectorTilePacket | null> {
return this.remote.renderVectorTile(tile, generation);
}
}

/** Create the required off-main-thread CLI worker pool. */
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/tile-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type SemanticRenderIndexTransferables,
} from "./semantic-render-index.ts";
import { drawStyledMapTile, drawStyledMapTileAsync } from "./styled-tile.ts";
import { buildVectorTile, type VectorTilePacket } from "./vector-tile.ts";

export interface MapLabelQueryRequest {
centerX: number;
Expand Down Expand Up @@ -282,6 +283,33 @@ export class CliTileWorker extends OsmixWorker {
return isCancelled() ? null : transfer(imageData);
}

getCliVectorTile(
id: string,
tile: Tile,
generation = 0,
cancellationState?: GenerationGateTransferables,
): VectorTilePacket | null {
const osm = this.get(id);
const renderIndex = this.getSemanticRenderIndex(id);
const nodeIndex = tile[2] >= 14 ? this.getSemanticNodeIndex(id) : undefined;
const cancellation = cancellationState
? GenerationGate.fromTransferables(cancellationState)
: null;
const isCancelled = () => cancellation?.isCancelled(generation) ?? false;
if (isCancelled()) return null;
const packet = buildVectorTile(
osm,
tile,
nodeIndex,
{
relations: renderIndex.relations(osm.relations, tile[2]),
ways: renderIndex.ways(osm.ways, tile[2]),
},
isCancelled,
);
return isCancelled() || !packet ? null : transfer(packet);
}

/** Render without SharedArrayBuffer while yielding often enough to service cancel RPCs. */
async getStyledRasterTileCooperatively(
id: string,
Expand Down Expand Up @@ -309,6 +337,29 @@ export class CliTileWorker extends OsmixWorker {
return isCancelled() ? null : transfer(imageData);
}

async getCliVectorTileCooperatively(
id: string,
tile: Tile,
generation = 0,
): Promise<VectorTilePacket | null> {
const isCancelled = () => this.tileGenerationGate.isCancelled(generation);
if (isCancelled()) return null;
const osm = this.get(id);
const renderIndex = this.getSemanticRenderIndex(id);
const nodeIndex = tile[2] >= 14 ? this.getSemanticNodeIndex(id) : undefined;
const packet = buildVectorTile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Yield while building cooperative vector tiles

On runtimes without SharedArrayBuffer, this supposedly cooperative path calls the fully synchronous buildVectorTile and does not yield until the entire tile is complete. Because cancelTilesBefore runs on the same worker event loop, pans and zooms cannot update tileGenerationGate during a complex tile build, so stale work blocks the sole fallback worker—and therefore newer tiles and label queries—until completion. Split vector construction into cooperative chunks or provide an async yielding implementation like drawStyledMapTileAsync.

Useful? React with 👍 / 👎.

osm,
tile,
nodeIndex,
{
relations: renderIndex.relations(osm.relations, tile[2]),
ways: renderIndex.ways(osm.ways, tile[2]),
},
isCancelled,
);
return isCancelled() || !packet ? null : transfer(packet);
}

private getSemanticNodeIndex(id: string): SemanticNodeIndex {
let cached = this.semanticNodeIndexes.get(id);
if (!cached || cached.contentHash !== this.get(id).contentHash()) {
Expand Down
Loading
Loading