docs(plans): 📝 Update and refine project planning documents in .claude/plans/ to reflect roadmap and strategic initiatives.
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
491167f118
commit
55abdcc1d9
2 changed files with 181 additions and 75 deletions
121
.claude/plans/adaptive-hugging-blossom.md
Normal file
121
.claude/plans/adaptive-hugging-blossom.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Sprite Stream Ticker — Live Ambient Display
|
||||
|
||||
## Context
|
||||
|
||||
The spritegen UI (`tools/sprite-generation/gui/`) currently has no real-time feedback when sprites are being generated. The dashboard polls `/api/progress` every 10s for aggregate counts, but there's no visual of actual images arriving. The goal is a beautiful ambient ticker across the top of the app that streams completed sprites horizontally — functioning as both a progress indicator and an idle screensaver.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Backend (SSE + REST) Frontend (React)
|
||||
|
||||
GET /api/variants/recent ─────────► Initial load (30 recent sprites)
|
||||
GET /api/stream/variants ─SSE──────► Real-time push of new completions
|
||||
│
|
||||
▼
|
||||
SpriteStream.tsx
|
||||
├── CSS @keyframes marquee (infinite scroll)
|
||||
├── SSE EventSource consumer
|
||||
├── Glow animation on new arrivals
|
||||
└── Pause on hover, click to navigate
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `engine/registry.py` — Add `get_recent_variants()`
|
||||
|
||||
New method on `SpriteRegistry` (after `get_pending_variants` ~line 427):
|
||||
|
||||
```python
|
||||
def get_recent_variants(self, limit: int = 30, since: str | None = None) -> list[dict]:
|
||||
```
|
||||
|
||||
- JOIN `variants v` to `sprites s` on `v.sprite_id = s.id`
|
||||
- Filter: `v.job_status = 'completed' AND v.raw_path IS NOT NULL`
|
||||
- Optional: `AND v.created_at > ?` when `since` provided
|
||||
- ORDER BY `v.created_at DESC`, LIMIT
|
||||
- Return: `variant_id, sprite_id, category, entity_id, raw_path, processed_path, seed, created_at`
|
||||
|
||||
### 2. `server.py` — Two new endpoints
|
||||
|
||||
**REST:** `GET /api/variants/recent?limit=30` — calls `registry.get_recent_variants()`, returns JSON array. Goes in the "Progress & Queue" section.
|
||||
|
||||
**SSE:** `GET /api/stream/variants` — async generator polling DB every 3s for new completions since last check. Sends `data: [...]` events with new variants, or SSE comment keepalives. Uses `StreamingResponse(media_type="text/event-stream")`. Add imports: `asyncio`, `StreamingResponse`.
|
||||
|
||||
### 3. `gui/src/types.ts` — Add `RecentVariant` interface
|
||||
|
||||
```typescript
|
||||
export interface RecentVariant {
|
||||
variant_id: number
|
||||
sprite_id: string
|
||||
category: string
|
||||
entity_id: string
|
||||
raw_path: string
|
||||
processed_path: string | null
|
||||
seed: number
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `gui/src/api.ts` — Add fetch helpers
|
||||
|
||||
- `fetchRecentVariants(limit = 30): Promise<RecentVariant[]>`
|
||||
- `variantStreamUrl(): string` — returns SSE endpoint URL
|
||||
|
||||
### 5. `gui/src/SpriteStream.tsx` — New component (the main piece)
|
||||
|
||||
**Visual design:**
|
||||
- Full-width strip at top of app, ~110px tall
|
||||
- Background: gradient fade from `colors.surface` to transparent — ambient, not blocky
|
||||
- Sprites scroll right-to-left infinitely via CSS `@keyframes` marquee
|
||||
- Each sprite: 80px tall image + 10px entity label below, semi-transparent (opacity 0.7)
|
||||
- New arrivals from SSE get a 3s highlight glow animation (`colors.highlight` box-shadow pulse)
|
||||
- Hover pauses the scroll, raises opacity to 1.0
|
||||
- Click navigates to `/sprite/{id}` for review
|
||||
- Empty state: subtle pulsing "Waiting for sprites..." text
|
||||
|
||||
**Marquee technique:**
|
||||
- Render the sprite array twice in a flex row (A + A clone)
|
||||
- `translateX(0) → translateX(-50%)` animation creates seamless infinite loop
|
||||
- Duration scales with sprite count (minimum 20s, ~2s per sprite)
|
||||
- `will-change: transform` for GPU compositing
|
||||
|
||||
**SSE lifecycle:**
|
||||
- On mount: fetch initial sprites, open EventSource
|
||||
- On SSE message: parse JSON, dedupe by variant_id, prepend to list, cap at 60 items
|
||||
- On SSE error: retry with backoff (5s → 10s → 20s, cap 30s), fall back to 10s polling after 3 failures
|
||||
- On unmount: close EventSource
|
||||
|
||||
**Injected `<style>` tag** for `@keyframes` (project uses inline styles only, keyframes can't be inline).
|
||||
|
||||
### 6. `gui/src/App.tsx` — Wire it in
|
||||
|
||||
Import `SpriteStream`, render it above the `<nav>` element. The ticker sits at the very top of the viewport across all pages.
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `engine/registry.py` | Add method |
|
||||
| `server.py` | Add 2 endpoints + imports |
|
||||
| `gui/src/types.ts` | Add interface |
|
||||
| `gui/src/api.ts` | Add 2 functions |
|
||||
| `gui/src/SpriteStream.tsx` | **Create** |
|
||||
| `gui/src/App.tsx` | Add import + component |
|
||||
|
||||
## Build Order
|
||||
|
||||
1. `registry.py` (no deps)
|
||||
2. `server.py` (depends on 1)
|
||||
3. `types.ts` + `api.ts` (parallel, no deps)
|
||||
4. `SpriteStream.tsx` (depends on 3)
|
||||
5. `App.tsx` (depends on 4)
|
||||
|
||||
## Verification
|
||||
|
||||
1. Start backend: `cd tools/sprite-generation && uvicorn server:app --port 5801 --reload`
|
||||
2. Test REST: `curl localhost:5801/api/variants/recent`
|
||||
3. Test SSE: `curl -N localhost:5801/api/stream/variants` (should see keepalive comments every 3s)
|
||||
4. Start frontend: `cd tools/sprite-generation/gui && pnpm dev`
|
||||
5. Open `localhost:5802` — ticker should appear with any completed sprites from the DB
|
||||
6. If generation is active, new sprites should appear with glow animation in real-time
|
||||
|
|
@ -1,100 +1,85 @@
|
|||
# Plan: Flat hex tiles in polar projection
|
||||
# Plan: Proper polar hex grid rendering
|
||||
|
||||
## Context
|
||||
|
||||
The current polar view (v2) uses nearest-neighbor sampling to create Voronoi-like cells from the azimuthal projection. These cells are warped/distorted by the projection — they don't look like the flat hex tiles in the equator view. The user wants actual regular flat hexagons placed at their polar-projected positions, with gaps between tiles that increase toward the edges.
|
||||
The current polar view attempts to project all 40 hex columns at every latitude ring into a circular disk. This is fundamentally broken because:
|
||||
- **Near the pole, 40 tiles converge to a point** — they can't all fit at uniform size
|
||||
- **Tile shapes warp** — the flat-top hex containment test doesn't account for angular position
|
||||
- **The center is unusable** — an unreadable mess of overlapping slivers
|
||||
|
||||
## Approach: hex-shape mask in the fragment shader
|
||||
## Research findings
|
||||
|
||||
Keep the existing polar projection pipeline but add a **hex-shape containment test**. For each pixel:
|
||||
Per [Red Blob Games](https://www.redblobgames.com/x/1640-hexagon-tiling-of-sphere/) and [CivFanatics sphere hex discussion](https://forums.civfanatics.com/threads/solution-the-globe-in-civ-v-a-really-spherical-map-with-only-hexagonal-tiles.356334/):
|
||||
|
||||
1. Map screen UV → grid (col, row) via `polarToGrid()` (existing)
|
||||
2. Convert to pixel space → find nearest hex center → get integer (col, row)
|
||||
3. Project that hex center **back to screen UV** via inverse polar (new function)
|
||||
4. Test if the pixel falls within a **regular flat-top hexagon** at that screen position (new function)
|
||||
5. If outside hex → dark background; if inside → color with hex data (existing layer logic)
|
||||
- **You cannot tile a sphere with uniform hexagons** — 12 pentagons are always required (Euler's theorem)
|
||||
- **Civ V/VI avoid the problem entirely** — they use a cylindrical map that wraps E-W but NOT N-S. Poles are just the top/bottom edge of a flat rectangle. There is no polar view.
|
||||
- **Icosahedron subdivision** is the proper approach for spherical hex grids, but our game uses a flat equirectangular grid (40×24), not a geodesic sphere
|
||||
- The fundamental issue: our grid has a **fixed column count at every latitude**. On a real sphere, polar latitudes should have fewer tiles.
|
||||
|
||||
This gives flat regular hexagons positioned in polar layout. Near the pole they cluster tightly; near the edges they spread apart with visible gaps.
|
||||
## Recommended approach: Column decimation with uniform tiles
|
||||
|
||||
## File: `guide/engine/src/components/climate-sim/hexGLShaders.ts`
|
||||
Since our grid is equirectangular (not geodesic), the polar view is a **visualization tool**, not a gameplay surface. The approach:
|
||||
|
||||
### New GLSL functions
|
||||
**For each latitude ring, compute how many columns physically fit at uniform tile size, and only render those columns evenly spaced around the ring.**
|
||||
|
||||
**`findNearestHex(px)`** — same search as `sampleNearest` but returns `vec2(col, row)` instead of texture data:
|
||||
```glsl
|
||||
vec2 findNearestHex(vec2 px) {
|
||||
// Same 5×3 neighborhood search as sampleNearest
|
||||
// Returns vec2(float(bestCol), float(bestRow))
|
||||
}
|
||||
```
|
||||
### Algorithm
|
||||
|
||||
**`gridToScreenUV(col, row, pole)`** — inverse polar projection (grid → screen):
|
||||
```glsl
|
||||
vec2 gridToScreenUV(float col, float row, int pole) {
|
||||
lonFrac = (col + 0.5) / mapWidth
|
||||
latFrac = row / (mapHeight - 1) [or inverted for south pole]
|
||||
angle = lonFrac * 2π - π
|
||||
radius = latFrac * 0.5
|
||||
return vec2(0.5 + radius*cos(angle)/aspect, 0.5 + radius*sin(angle))
|
||||
}
|
||||
```
|
||||
1. Compute uniform hex size from zoom level (row spacing)
|
||||
2. For each row ring at radius `r` from the pole center:
|
||||
- Compute circumference: `C = 2π × r`
|
||||
- Compute how many uniform-width tiles fit: `maxCols = floor(C / tileWidth)`
|
||||
- Clamp to `[1, 40]`
|
||||
- Compute column step: `colStep = max(1, round(40 / maxCols))`
|
||||
- Only render columns that are multiples of `colStep`
|
||||
3. For the pole center (row 0, radius ≈ 0): render a single centered hex
|
||||
4. Each rendered tile samples data from its grid column, giving a representative view
|
||||
|
||||
**`hexContains(pixelUV, centerUV, hexR)`** — flat-top regular hexagon test in aspect-corrected screen space:
|
||||
```glsl
|
||||
bool hexContains(vec2 pUV, vec2 cUV, float hexR) {
|
||||
// Aspect-correct the delta
|
||||
// Flat-top hex test: |dx| <= R, |dy| <= R*√3/2, |dx|*√3/2 + |dy|*0.5 <= R*√3/2
|
||||
}
|
||||
```
|
||||
### Why not octagons?
|
||||
|
||||
### Modified polar path in `main()`
|
||||
Octagons don't tile a plane (they leave square gaps). The research shows the best approach is hexagons + pentagons (geodesic), but since our data is on a fixed 40-column grid, the column decimation approach is the pragmatic solution. The tiles remain proper flat-top hexagons — we just show fewer of them near the pole.
|
||||
|
||||
Replace the current polar branch (lines ~242-263) with:
|
||||
### Shader changes (`hexGLShaders.ts`)
|
||||
|
||||
In the polar path of `main()`, after finding the nearest hex `(nearCol, nearRow)`:
|
||||
|
||||
```glsl
|
||||
} else {
|
||||
vec2 grid = polarToGrid(vUv, uViewCenter);
|
||||
if (grid.x < 0.0) { outsideDisk = true; px = vec2(0.0); }
|
||||
else {
|
||||
px = gridToPx(grid);
|
||||
vec2 nearHex = findNearestHex(px);
|
||||
float nearCol = nearHex.x;
|
||||
float nearRow = nearHex.y;
|
||||
// How many columns fit at this ring's circumference
|
||||
float rSpace = polarRowSpacing();
|
||||
float rowOffset = (uViewCenter == 1) ? nearRow : (uMapSize.y - 1.0) - nearRow;
|
||||
float radius = rowOffset * rSpace;
|
||||
float hw = rSpace * 0.45 * (uHexW / uHexH); // hex half-width from zoom
|
||||
float tileWidth = hw * 2.5; // tile width + gap
|
||||
float maxCols = max(1.0, floor(6.28318 * radius / tileWidth));
|
||||
float colStep = max(1.0, ceil(uMapSize.x / maxCols));
|
||||
|
||||
// Project hex center to screen for shape test
|
||||
vec2 hexScreenUV = gridToScreenUV(nearCol, nearRow, uViewCenter);
|
||||
|
||||
// Hex size: radial spacing, scaled down near pole by tangential spacing
|
||||
float radialR = 0.5 / (uMapSize.y - 1.0);
|
||||
float latFrac = (uViewCenter == 1) ? nearRow / (uMapSize.y - 1.0) : 1.0 - nearRow / (uMapSize.y - 1.0);
|
||||
float tangentialR = 3.14159265 * latFrac * 0.5 / uMapSize.x;
|
||||
float hexR = min(radialR, tangentialR) * 0.8;
|
||||
hexR = max(hexR, 0.003); // minimum visible size at pole
|
||||
|
||||
if (!hexContains(vUv, hexScreenUV, hexR)) {
|
||||
gl_FragColor = vec4(0.04, 0.03, 0.06, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample data for this specific hex
|
||||
vec2 dataUV = vec2((nearCol + 0.5) / uMapSize.x, (nearRow + 0.5) / uMapSize.y);
|
||||
s.a = texture2D(uTexA, dataUV);
|
||||
s.b = texture2D(uTexB, dataUV);
|
||||
}
|
||||
}
|
||||
// Snap to nearest visible column
|
||||
float snappedCol = floor(nearCol / colStep + 0.5) * colStep;
|
||||
snappedCol = mod(snappedCol, uMapSize.x);
|
||||
```
|
||||
|
||||
### Delta layer update
|
||||
Then use `snappedCol` for `gridToScreenUV` and data sampling. The `hexContains` test stays the same — uniform size at all positions.
|
||||
|
||||
The delta layer block also needs to use `findNearestHex` + direct texture lookup in polar mode (same pattern as main sampling).
|
||||
### Renderer changes (`HexGLRenderer.tsx`)
|
||||
|
||||
## No changes needed in other files
|
||||
Already done: `uPolarRows` uniform + mouse wheel handler. No additional changes needed.
|
||||
|
||||
`HexGLRenderer.tsx` and `ClimateSimDisplay.tsx` are already correct from the previous iteration.
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `guide/engine/src/components/climate-sim/hexGLShaders.ts` | Column decimation in polar path |
|
||||
|
||||
## Verification
|
||||
|
||||
1. `?view=equator` — unchanged flat hex grid
|
||||
2. `?view=north` — flat regular hexagons in polar layout, ice at center, gaps increasing toward edges
|
||||
3. `?view=south` — same from south pole
|
||||
4. Take Playwright screenshots of all 3 views
|
||||
1. `?view=north` — pole center shows 1-3 tiles, rings radiate outward with increasing column count
|
||||
2. All visible tiles are the same size flat-top hexagons
|
||||
3. Mouse wheel zooms in/out (fewer/more rows, larger/smaller tiles)
|
||||
4. `?view=equator` — completely unchanged
|
||||
5. Take Playwright screenshots at multiple zoom levels
|
||||
|
||||
## Sources
|
||||
|
||||
- [Red Blob Games: Hex tiling of sphere](https://www.redblobgames.com/x/1640-hexagon-tiling-of-sphere/)
|
||||
- [CivFanatics: Globe in Civ V](https://forums.civfanatics.com/threads/solution-the-globe-in-civ-v-a-really-spherical-map-with-only-hexagonal-tiles.356334/)
|
||||
- [D3 Azimuthal Equidistant](https://d3js.org/d3-geo/azimuthal)
|
||||
- [Freeciv Sphere discussion](https://freeciv.fandom.com/wiki/Sphere)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue